Compare commits

39 Commits
Author SHA1 Message Date
nvrl 6e09129d0c fixed font loading 2026-10-03 02:03:24 +02:00
nvrl 476fec8c04 chore(VER): bumped 0.5.0 2026-10-02 23:42:13 +02:00
nvrl 7af0969e38 fix(stream): fetch a block again after a retriable failure
The SDK retries a request that fails before its response starts, but not a
block body cut off part way through. That error stopped playback or failed
a download, and the SDK's new keep-alive pings make it the way a dead
connection now ends: seconds in, rather than at the storage timeout.

A block is now fetched up to three times, 250 ms and then 500 ms apart,
while ProtonError::is_retriable says a replay could succeed. A permanent
refusal still fails on the first attempt.
2026-10-02 23:42:12 +02:00
nvrl 3df0644bd8 chore(deps): proton-sdk and proton-drive-rs 0.7.2, lru 0.18
The SDK now pings idle HTTP/2 connections and bounds the connect phase, so
a connection dropped by a NAT or a network switch is replaced within seconds
instead of stalling every request on it until the timeout. It also clamps a
server's Retry-After to two minutes, re-mints expired block URLs on the
whole-file download path as well as the reader, and exposes
ProtonError::is_retriable.

lru follows the SDK to 0.18, so one copy is built instead of two.
2026-10-02 23:42:12 +02:00
nvrl 5d079b05c9 feat(ios): a sideloadable iOS client over the shared Rust engine
A SwiftUI app that drives the same UniFFI engine as Android: Rust owns the
Proton sessions, SQLite and every decrypted byte, and Swift asks it questions
off the main thread. Playback goes through MPVKit's libmpv, with the pstr://
stream protocol ported from the Android JNI half, and secrets live in the
Keychain.

scripts/build-ios.sh builds the static library for aarch64-apple-ios, writes
the Swift bindings (module PstrBridge), generates the Xcode project with
XcodeGen and packages an unsigned .ipa for SideStore or AltStore, which
re-sign it with the viewer's own Apple ID. The generated project, Info.plist
and build output are ignored.
2026-10-02 23:34:17 +02:00
nvrl 4d44db0949 feat(app): desktop notifications on Windows
Toasts through WinRT, under the app id io.narl.proton-stream. The id is
registered for the current user under HKCU\Software\Classes\AppUserModelId
with a display name and icon on the first notification of a session, which
is what lets an app with no package or installer-made shortcut show one. If
that write fails, the toast goes out under PowerShell's id rather than not
at all.
2026-10-02 23:27:25 +02:00
nvrl 4a54af3245 feat(app): one window at a time, share link as an argument
A second launch hands its request to the open window and exits: over a
Unix socket in the user's runtime directory, or a named pipe on Windows
whose name carries a random key from the user's config directory, so
another account cannot take the name first. The open window comes forward,
and a share link given as the first such argument opens the Add form with
it filled in. Setup failures are logged and the app runs without the lock.
2026-10-02 23:24:28 +02:00
nvrl 7e734dbf19 feat(app): an episode list beside the picture
A button in the player's title strip, or E, opens the title's episodes
down the right of the picture, a season at a time, starting on the
playing season and scrolled to the playing episode. Each row has its still
(which plays it), its name, and whether it is playing, watched or part
way. The controls stay up while the list is out, and Esc closes it before
it leaves fullscreen or the player.
2026-10-02 23:20:28 +02:00
nvrl e7eeaa518f feat(core,app): a history page
Every played episode, newest first, under Today, Yesterday, a weekday or a
date. A row shows the still (which plays it), the title (which opens it),
the episode, and where it stopped or that it was finished. Removing a row
resets that file to never played, with an Undo. History sits second in the
top bar, so Ctrl+1 to Ctrl+5 now run Library, History, Shares, Downloads,
Settings.
2026-10-02 23:17:36 +02:00
nvrl 39866bee8d perf(app): read the metadata API key and the share list off the UI thread
A provider with an API key read it from the OS credential store inside
Engine::new, which is a D-Bus round trip to a keyring that may have to
unlock first. It is now built on the blocking pool. Until it lands, poster
fetches, match runs and match searches wait for it rather than concluding
there is no provider and marking posters missing. A settings change made
in the meantime wins over the launch-time build. The share list is read
on the blocking pool too.
2026-10-02 23:12:25 +02:00
nvrl 85092fa893 refactor(app): spacing, radius and motion tokens
theme::space, theme::radius and theme::motion name the steps the UI is
drawn with. Literal gaps, corner radii and animation durations across ui/
now come from them, which folds a few near-misses onto the scale (10 and 14
point gaps onto 12, 10 point radii onto 12, 0.10 s hovers onto 0.12 s).
Page fades and toasts share one ease_out curve.
2026-10-02 23:10:39 +02:00
nvrl 2128a2d705 fix(app): the highest episode row on screen no longer stretches to the window bottom
The watched and download buttons were laid out with with_layout(right_to_left,
Center), which takes every point of height the scroll area has left and
centres the buttons in it. Give the cluster the still's height instead.
2026-10-02 23:08:30 +02:00
nvrl 558446b9e4 feat(app): bundled Inter and Phosphor icons, window icon
Headings are set in Inter SemiBold, everything else in Inter Regular, so a
title sets the same on every desktop. Media and navigation glyphs (⏭, 🔊, ⛶,
★) come from the Phosphor icon font instead of whichever fallback font had
them. The CJK fallback still loads on its own thread and now joins the
bundled fonts instead of replacing them. The window carries the app icon
on X11 and Windows.
2026-10-02 23:07:05 +02:00
nvrl 6518aef5ee docs: new config files, scaled art caches, media widget privacy 2026-10-02 20:16:27 +02:00
nvrl 9037e6c2d5 feat(app,player): page fade, Ctrl+S screenshots
- a page fades in over 140 ms when the page changes, rather than cutting
- Ctrl+S in the player saves the frame, without subtitles, to
  Pictures/proton-stream as "Title  S01E04  12-34.png"
2026-10-02 20:16:27 +02:00
nvrl d3325843a9 feat(app): keyboard navigation across the library and episode list
Tiles and episode stills light up under focus as they do under the
pointer and scroll into view when the arrow keys or Tab reach them;
Enter opens or plays. Global keys now check for a focused text field
rather than any focused widget, so / and Esc still work over a tile.
2026-10-02 20:16:27 +02:00
nvrl 79c3f6885d feat(app): undo on toasts for marking a title and leaving Continue watching
A toast can carry a button. Marking a whole title watched or unwatched
from its tile menu, and taking it off Continue watching, both lose
state the viewer may have wanted; each now says what it did with an
Undo that puts every changed watch state back as it was.
2026-10-02 20:16:27 +02:00
nvrl a432a93cf9 feat(app,stream): storage and hardware-decoding settings
- the streaming cache's budget (1-16 GiB) is chosen in Settings, shows
  what it holds, and can be emptied; a lower budget evicts at once
- hardware decoding can be turned off for a driver that shows green
  frames, from the next file
- both live in desktop.json rather than the playback prefs shared with
  Android, whose bridge record would reset them on every save. A file
  that will not parse is never overwritten
2026-10-02 20:16:27 +02:00
nvrl 935a985589 feat(app): live picture in the mini-player
The film keeps playing small at the left of the transport bar while
browsing, and is the button back to the player page. It sits beside
both rows of controls, so the bar is no taller for it, and renders at
thumbnail size four times a second rather than at the film's rate.
2026-10-02 20:16:27 +02:00
nvrl 7d03736954 feat(app): a featured-title banner at the top of the library
The title watched last, or with nothing part-watched one of the titles a
provider has a backdrop for, turning over once a day. Large art behind
the name, rating, genres, three lines of overview, and Resume / More
info.
2026-10-02 20:16:27 +02:00
nvrl 53a1d0afda feat(app): desktop notifications, paste a share link anywhere
- a finished batch of downloads, and a crawl long enough to walk away
  from, notify through org.freedesktop.Notifications when the window is
  not focused; once per batch, not once per file
- Ctrl+V with a share link outside a text field opens the add form
  with the link in it
2026-10-02 20:16:27 +02:00
nvrl 959547cdc8 feat(app): media keys and desktop media widget, keep the screen on during a film
- MPRIS on Linux and SMTC on Windows through souvlaki: play/pause, next,
  previous, stop and seeking from media keys and the panel widget, with
  the title, episode, duration and the cached cover as a file:// URL
- the screensaver is held off while a film plays on the player page,
  through the desktop portal with org.freedesktop.ScreenSaver as the
  fallback, and SetThreadExecutionState on Windows. mpv's own
  stop-screensaver has no window to act for under the render API
2026-10-02 20:16:27 +02:00
nvrl c60ffc7ab1 feat(app): up-next card with the next still and a countdown ring, remember the window size
The up-next card shows the next episode's still with the seconds left
running round it, and the still itself plays it. The window opens at
the size and maximized state it closed at; position is left to the
window manager.
2026-10-02 20:16:27 +02:00
nvrl c5808a5e50 feat(app,player): tile context menu, buffered range on the seek bar, stall spinner
- right-click a library tile to play or resume it, mark it watched or
  unwatched, download it, change its match, or take it off Continue
  watching
- mpv's demuxer-cache-time is shaded on the seek bar, so it shows
  whether a jump lands in what is already read ahead
- paused-for-cache shows a spinner over the picture and in the controls
2026-10-02 20:16:27 +02:00
nvrl 41d1fbf073 feat(app): crawl shares in parallel, with a stop button
Each share is its own session against its own tree, so one large share
no longer holds the rest back. Stop ends every listing still running;
a share already past its listing finishes storing what it found, since
stopping halfway through replacing its rows would leave them disagreeing
with its downloads.
2026-10-02 19:36:41 +02:00
nvrl ff362d0d08 perf(app,core): bounded image cache, shrunk poster cache, in-place watch updates
- ImageCache drops the picture asked for longest ago past 256 MiB and
  uploads at most six textures a frame
- posters and thumbnails larger than a card are stored scaled down, so
  a cached grid no longer decodes and resizes full backdrops every launch
- a saved watch state patches the library in place instead of rebuilding
  it from the catalog; the offline index reuses the library just built
- download byte progress sends one row, not a sorted copy of every row
- the CJK fallback font is found and read off the UI thread
- the watch-state upsert uses a cached statement
2026-10-02 19:34:45 +02:00
nvrl 4027ab5fb3 feat(app): polish pass - toasts, shortcuts, redesigned pages, cheaper frames
- toasts replace the status line; keyboard shortcuts with a ? overlay
- library: filters, sort, row virtualisation, cached search, skeleton
  tiles until the catalog loads, gradient placeholder art
- title page: season pills, episode rows with stills and one download
  glyph, portrait poster, clamped overview, resume time in the button
- player: fading chrome, smooth scrims, subtitles lifted over the
  controls, drag-following seek knob, OSD, speed menu, auto-skip
- settings as grouped cards with toggles and palette previews
- shares: confirm before removing, inline link validation, form kept
  until the add succeeds; downloads scroll and show rate and ETA
- downloads pipeline four blocks; the next episode is warmed near the end
- a film behind another page repaints at 4 Hz, not its frame rate

Fixes B55-B60.
2026-10-02 19:27:28 +02:00
nvrl c5adcddb1e feat(player): subtitle position and playback speed 2026-10-02 19:27:27 +02:00
nvrl 8f78f5c1b1 perf(stream,core): take the disk cache off the read path, warm file edges, report crawl progress
A block the player waits for no longer waits on its disk-cache write and
two fsyncs; the put runs in a spawned task and only the block itself is
synced before the rename. Opening a stream can fetch the first and last
block together, where MKV cues and the MP4 moov live. Downloads open a
copying stream that reads the disk cache but never fills it.

crawl_reporting reports the running file count after each listing.
2026-10-02 19:27:27 +02:00
nvrl 6f28a9c377 chore(deps) bump proton-sdk/proton-drive-rs to 0.6.3, version 0.4.2
Adds the new Node fields (direct_role, share_id) to the catalog test fixture and applies rustfmt to the track-prefs test.
2026-09-15 14:10:00 +02:00
nvrl d3c5f071fb feat(player,core): remember the audio/subtitle track by name across episodes
A track picked on one episode now carries over to the next by its title as
well as its language, so e.g. "Signs & Songs" vs "Full" subtitles in the same
language stick. Catalog migration V8 adds the title columns; the Android
bridge keeps a stored title only while its language is unchanged.
2026-09-15 13:54:07 +02:00
nvrl ad24557588 feature(gui) themes + chore(deps) version bump 2026-08-15 14:49:04 +02:00
nvrl 4e6778e1ea version bump to 0.4.0 2026-08-10 01:23:22 +02:00
nvrl dc85890a9f docs: android review ledger, design tokens, test notes
B14-B42 record the Android review the theme and lifecycle work came out of.
DESIGN-TOKENS.md states what both clients agree on and where each keeps its
copy.
2026-08-10 01:10:13 +02:00
nvrl 4ef62255c0 feat(android): screens on the shared tokens; shelf scrolls with the grid
Player and library screens drop hardcoded blacks, whites and radii for
scheme roles and shape tokens, and use the shared button wrappers.

Continue watching was pinned above the poster grid, holding a fixed slice
of the screen on every scroll. It is now a full-span item at the top of the
grid, so it scrolls away with the rest.
2026-08-10 01:10:08 +02:00
nvrl 1a9a4624f8 feat(android): palette resolved before the first frame
storedPalette() reads and resolves the theme file without building the
engine, so onCreate can set the window background and system-bar icon
polarity before anything draws. Previously the background was a hex literal
in themes.xml and the bar icons were pinned dark, which meant every cold
start flashed one flavour's colour and Latte drew white icons on a
near-white page.

Adds the Material scheme mapping, type ramp and shape tokens the palette
feeds, plus unit tests for both.
2026-08-10 01:10:08 +02:00
nvrl cb03f96eca feat(app): design tokens, settings page, backdrop layering
Type/shape/motion tokens live in theme.rs and every ui/ module reads them
instead of scattering literal sizes and radii. Appearance and playback
preferences move out of the shares page into their own Settings page and
nav tab.

The title backdrop's veil and fades were painted after the header was laid
out, so they covered the name and overview as well as the still — the text
was being dimmed by the thing meant to make it readable. All band layers
now go into the shape slot reserved before the header, behind the text.
2026-08-10 01:09:58 +02:00
nvrl 46bb68e3a5 improved android ui 2026-08-09 19:12:41 +02:00
nvrl f1b3ad9637 improved android client, feature parity with desktop 2026-08-09 16:40:37 +02:00
124 changed files with 21979 additions and 3409 deletions

No files matched your search

+6
View File
@@ -20,3 +20,9 @@
/packaging/pkg/ /packaging/pkg/
/packaging/src/ /packaging/src/
/packaging/*.pkg.tar.zst /packaging/*.pkg.tar.zst
# iOS generated state: XcodeGen owns the project and the Info.plist, and
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
/ios/build/
/ios/*.xcodeproj/
/ios/ProtonStream/Info.plist
Generated
+252 -45
View File
@@ -119,12 +119,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@@ -609,6 +603,12 @@ dependencies = [
"digest 0.10.7", "digest 0.10.7",
] ]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -1029,6 +1029,36 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types",
"libc",
"objc",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.13.1" version = "0.13.1"
@@ -1136,6 +1166,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types 0.3.2",
"libc",
]
[[package]] [[package]]
name = "core-graphics" name = "core-graphics"
version = "0.23.2" version = "0.23.2"
@@ -1145,7 +1188,7 @@ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -1377,6 +1420,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "dbus-crossroads"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8"
dependencies = [
"dbus",
]
[[package]] [[package]]
name = "dbus-secret-service" name = "dbus-secret-service"
version = "4.1.0" version = "4.1.0"
@@ -1709,6 +1761,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "egui-phosphor"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
dependencies = [
"egui",
]
[[package]] [[package]]
name = "egui-wgpu" name = "egui-wgpu"
version = "0.35.0" version = "0.35.0"
@@ -2003,12 +2064,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -2024,6 +2079,15 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -2031,7 +2095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -2045,6 +2109,12 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -2279,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"foldhash 0.2.0", "foldhash",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"log", "log",
"peniko", "peniko",
@@ -2403,6 +2473,25 @@ dependencies = [
"euclid", "euclid",
] ]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@@ -2436,24 +2525,13 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [ dependencies = [
"foldhash 0.2.0", "foldhash",
] ]
[[package]] [[package]]
@@ -2462,7 +2540,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [ dependencies = [
"foldhash 0.2.0", "foldhash",
] ]
[[package]] [[package]]
@@ -2592,6 +2670,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -3150,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.12.5" version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.17.1",
] ]
[[package]] [[package]]
@@ -3163,6 +3242,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -3483,6 +3571,15 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]] [[package]]
name = "objc-sys" name = "objc-sys"
version = "0.3.5" version = "0.3.5"
@@ -4202,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.4.0" version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b162681255e296b48ef7261cad899ffb384756e866a1c6d7b7c791cbbc8af3ac" checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.23.0",
"bytes", "bytes",
"futures", "futures",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -4229,13 +4326,13 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.4.0" version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d9b94ec901edb0e849238ea833121f2fb173308b4927b8aa6b157643fa0a73" checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.23.0",
"bcrypt", "bcrypt",
"bytes", "bytes",
"chrono", "chrono",
@@ -4262,7 +4359,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni 0.21.1", "jni 0.21.1",
@@ -4281,11 +4378,15 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"dbus",
"directories",
"eframe", "eframe",
"egui", "egui",
"egui-phosphor",
"futures", "futures",
"image", "image",
"parking_lot", "parking_lot",
@@ -4293,17 +4394,22 @@ dependencies = [
"pstr-meta", "pstr-meta",
"pstr-player", "pstr-player",
"pstr-stream", "pstr-stream",
"raw-window-handle",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"skrifa",
"souvlaki",
"tauri-winrt-notification",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -4321,7 +4427,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -4339,7 +4445,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -4352,12 +4458,13 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
"libmpv2", "libmpv2",
"libmpv2-sys", "libmpv2-sys",
"pstr-core",
"pstr-stream", "pstr-stream",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
@@ -4366,7 +4473,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
@@ -4646,6 +4753,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -5400,6 +5508,24 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "souvlaki"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
dependencies = [
"base64 0.22.1",
"block",
"cocoa",
"core-graphics 0.22.3",
"dbus",
"dbus-crossroads",
"dispatch",
"objc",
"thiserror 1.0.69",
"windows 0.44.0",
]
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.9" version = "0.9.9"
@@ -5488,6 +5614,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tauri-winrt-notification"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
dependencies = [
"thiserror 2.0.19",
"windows 0.62.2",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5643,6 +5780,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"libc",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@@ -6538,6 +6676,36 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "windows"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -6551,6 +6719,17 @@ dependencies = [
"windows-strings", "windows-strings",
] ]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@@ -6579,6 +6758,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -6714,6 +6903,24 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -6910,7 +7117,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"concurrent-queue", "concurrent-queue",
"core-foundation 0.9.4", "core-foundation 0.9.4",
"core-graphics", "core-graphics 0.23.2",
"cursor-icon", "cursor-icon",
"dpi", "dpi",
"js-sys", "js-sys",
+23 -4
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.2.0" version = "0.5.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2 opt-level = 2
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.4.0" } proton-sdk = { version = "0.7.2" }
proton-drive-rs = { version = "0.4.0" } proton-drive-rs = { version = "0.7.2" }
# Internal crates. # Internal crates.
pstr-core = { path = "crates/pstr-core" } pstr-core = { path = "crates/pstr-core" }
@@ -51,6 +51,8 @@ anyhow = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5" directories = "5"
# Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10" sha2 = "0.10"
# `bundled` so a Windows build needs no system SQLite. # `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
"x11", "x11",
] } ] }
egui = "0.35" egui = "0.35"
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
# font has them, at its own size and baseline; an icon font is one set.
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
# Tests only: reads the bundled fonts' character maps. The version epaint
# already builds, so it costs no extra compile.
skrifa = { version = "0.42", default-features = false, features = ["std"] }
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also # Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
# pulls in every exotic format is weight this app has no use for. # pulls in every exotic format is weight this app has no use for.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
@@ -84,9 +92,20 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
# behind a poisoned lock for the rest of the session. Same reason # behind a poisoned lock for the rest of the session. Same reason
# `proton-drive-linux` uses it. # `proton-drive-linux` uses it.
parking_lot = "0.12" parking_lot = "0.12"
# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows.
# `dbus` rather than `zbus` because the keyring's Secret Service store already
# links libdbus, so this adds no second D-Bus stack.
souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] }
# Holding the screensaver off while a film plays. mpv does that for a window of
# its own, but the picture here is drawn into ours, and mpv has no window to
# hold it for.
dbus = "0.9"
windows-sys = "0.61"
# Toasts on Windows. Linux notifications go over the `dbus` above.
tauri-winrt-notification = "0.8"
rpassword = "7" rpassword = "7"
libloading = "0.8" libloading = "0.8"
lru = "0.12" lru = "0.18"
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the # `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can # stream layer needs a `dyn` block source so its cache, ring and read-ahead can
# be tested against a fake without a network or an account. # be tested against a fake without a network or an account.
+17 -3
View File
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
```bash ```bash
cargo run --release -p pstr-app # the app cargo run --release -p pstr-app # the app
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
``` ```
One window at a time: a second launch brings the open window forward and
hands it any share link it was given, then exits.
The `pstr` CLI does the same work headlessly, and is what tells you which layer The `pstr` CLI does the same work headlessly, and is what tells you which layer
is broken: is broken:
@@ -67,7 +71,7 @@ against the SDK's own range download.
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. | | Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
| Transcoding | None. mpv demuxes and decodes natively. | | Transcoding | None. mpv demuxes and decodes natively. |
| UI | egui/eframe on glow; mpv renders into the same GL context. | | UI | egui/eframe on glow; mpv renders into the same GL context. |
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. | | Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. | | Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. | | Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
@@ -103,11 +107,21 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
a local HTTP server: there is no port for another process on the machine to ask a local HTTP server: there is no port for another process on the machine to ask
for the plaintext, and mpv is handed an opaque token rather than a URL that for the plaintext, and mpv is handed an opaque token rather than a URL that
would put a share secret in its log and window title. would put a share secret in its log and window title.
- The block cache holds decrypted content. It lives in your cache directory and - The block cache holds decrypted content. It lives in your cache directory,
can be deleted at any time the app is not running. can be deleted at any time the app is not running, and can be sized or
emptied from Settings → Storage.
- A share link given as a command-line argument is visible to other users of
the machine in the process list while that launch runs, and may land in
shell history. Pasting it into the window (Ctrl+V) does neither.
- The desktop's media widget is handed the title, the episode and a `file://`
path to artwork already on disk — never a provider URL, so the shell never
contacts a metadata provider on its own.
## License ## License
The shared Rust crates and desktop application are MIT licensed. The Android The shared Rust crates and desktop application are MIT licensed. The Android
application and its combined native distribution are GPL-3.0-or-later; see application and its combined native distribution are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices. [`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
The desktop application embeds the Inter typeface, under the SIL Open Font
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
and the Phosphor icon font, under MIT.
+29
View File
@@ -0,0 +1,29 @@
# ktlint configuration.
#
# The whole standard ruleset is off, and rules are enabled one at a time. That
# is deliberate: turning it all on reports ~540 findings on the existing tree,
# almost all of them line-wrapping preference, and a mechanical reformat of
# every file would bury the changes that matter in review. The rules kept here
# are the ones whose violation is a defect or a real maintenance cost rather
# than a matter of taste — starting with the unused imports that were the
# evidence no gate ran at all.
#
# Adding a rule is cheap. Do it with the reformat it implies in its own commit.
[*.{kt,kts}]
ktlint_standard = disabled
ktlint_standard_no-unused-imports = enabled
ktlint_standard_no-wildcard-imports = enabled
ktlint_standard_no-semi = enabled
ktlint_standard_no-trailing-spaces = enabled
ktlint_standard_no-consecutive-blank-lines = enabled
ktlint_standard_no-empty-file = enabled
ktlint_standard_final-newline = enabled
ktlint_standard_filename = enabled
ktlint_standard_no-line-break-before-assignment = enabled
ktlint_standard_no-multi-spaces = enabled
# Vendored from rustls-platform-verifier. Restyling it would turn the next sync
# with upstream into a merge conflict for no benefit.
[**/org/rustls/**.kt]
ktlint = disabled
+78 -2
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ktlint)
} }
val repositoryRoot = rootProject.projectDir.parentFile val repositoryRoot = rootProject.projectDir.parentFile
@@ -53,6 +54,7 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = releaseVersionCode versionCode = releaseVersionCode
versionName = releaseVersionName versionName = releaseVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { ndk {
abiFilters += listOf("arm64-v8a", "x86_64") abiFilters += listOf("arm64-v8a", "x86_64")
@@ -103,7 +105,51 @@ android {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
if (releaseStore != null) signingConfig = signingConfigs.getByName("release") if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
// Applied to the shipping APK on purpose; the file explains the
// trade-off and what each keep is repairing.
"proguard-instrumentation.pro",
)
testProguardFiles("proguard-test-rules.pro")
}
}
// The minified path is what ships, and R8 breaks reflective UniFFI/JNA
// surfaces in ways no debug run can see. CI sets this to `release` and runs
// connectedAndroidTest against it; locally the default keeps builds fast.
testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug"
lint {
warningsAsErrors = true
abortOnError = true
checkDependencies = true
// The baseline records what was already here when the gate went in, so
// anything *new* fails. It is not a permanent exemption: every entry in
// it maps to an open bug in docs/BUGS.md (the SharedPreferences
// `commit()` calls are B20, the PiP one is B40), and the file should get
// shorter as those land. Regenerate with `gradlew updateLintBaseline`
// after a fix, and never to make an unrelated failure go away.
baseline = file("lint-baseline.xml")
// Generated UniFFI bindings are not ours to fix.
disable += setOf("UnusedIds")
// Dependency freshness is not a defect, and as errors these break the
// gate whenever someone else cuts a release. Upgrades are a deliberate
// act with their own verification; `gradlew lintDebug -Dlint...` or a
// dependency bot is the right place to surface them, not CI.
informational += setOf(
"GradleDependency",
"NewerVersionAvailable",
"AndroidGradlePluginVersion",
)
sarifReport = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
} }
} }
@@ -209,7 +255,6 @@ dependencies {
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.jna) { artifact { type = "aar" } } implementation(libs.jna) { artifact { type = "aar" } }
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
@@ -219,5 +264,36 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.navigation.suite) implementation(libs.androidx.compose.material3.navigation.suite)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
} }
ktlint {
version.set("1.5.0")
android.set(true)
ignoreFailures.set(false)
filter {
// UniFFI writes these; they are regenerated on every build.
exclude { it.file.path.contains("${File.separator}generated${File.separator}") }
// Vendored from rustls-platform-verifier. Restyling it would make the
// next sync with upstream a merge conflict for no benefit.
exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") }
}
}
// The generated UniFFI directory is a main source dir, so ktlint reads it even
// though the filter above drops every file in it. Without this, Gradle refuses
// the build for an undeclared dependency between the two tasks.
tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }
.configureEach { dependsOn(generateUniFfiBindings) }
+268
View File
@@ -0,0 +1,268 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.13.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.13.2)" variant="all" version="8.13.2">
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 8.13.2 is available: 9.3.1"
errorLine1="agp = &quot;8.13.2&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="2"
column="7"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose:compose-bom than 2026.06.00 is available: 2026.06.01"
errorLine1="compose-bom = &quot;2026.06.00&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="4"
column="15"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity-compose than 1.11.0 is available: 1.13.0"
errorLine1="activity = &quot;1.11.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="5"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.9.4 is available: 2.11.0"
errorLine1="lifecycle = &quot;2.9.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="6"
column="13"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-viewmodel-compose than 2.9.4 is available: 2.11.0"
errorLine1="lifecycle = &quot;2.9.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="6"
column="13"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.9.5 is available: 2.9.8"
errorLine1="navigation = &quot;2.9.5&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="7"
column="14"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-runtime-ktx than 2.10.5 is available: 2.11.2"
errorLine1="work = &quot;2.10.5&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="8"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-testing than 2.10.5 is available: 2.11.2"
errorLine1="work = &quot;2.10.5&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="8"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose.material3.adaptive:adaptive than 1.1.0 is available: 1.2.0"
errorLine1="adaptive = &quot;1.1.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="9"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose.material3:material3-adaptive-navigation-suite than 1.1.0 is available: 1.4.0"
errorLine1="adaptive = &quot;1.1.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="9"
column="12"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.19.0"
errorLine1="androidx-core = { module = &quot;androidx.core:core-ktx&quot;, version = &quot;1.17.0&quot; }"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="19"
column="64"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.android than 2.2.21 is available: 2.4.10"
errorLine1="kotlin = &quot;2.2.21&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="3"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.21 is available: 2.4.10"
errorLine1="kotlin = &quot;2.2.21&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="3"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of net.java.dev.jna:jna than 5.17.0 is available: 5.19.1"
errorLine1="jna = &quot;5.17.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="10"
column="7"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.robolectric:robolectric than 4.16 is available: 4.16.1"
errorLine1="robolectric = &quot;4.16&quot;"
errorLine2=" ~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="12"
column="15"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jlleitschuh.gradle.ktlint than 13.1.0 is available: 14.2.0"
errorLine1="ktlint-plugin = &quot;13.1.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="../gradle/libs.versions.toml"
line="16"
column="17"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; `SDK_INT` is always >= 31"
errorLine1=" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt"
line="294"
column="13"/>
</issue>
<issue
id="AutoboxingStateCreation"
message="Prefer `mutableIntStateOf` instead of `mutableStateOf`"
errorLine1=" var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/playback/PlayerScreen.kt"
line="707"
column="47"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
line="41"
column="15"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" check(preferences.edit().remove(key).commit())"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
line="64"
column="15"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="10"
column="22"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="14"
column="22"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTO_SKIP, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="25"
column="22"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTOPLAY, value).apply() }"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
line="30"
column="22"/>
</issue>
</issues>
+98
View File
@@ -0,0 +1,98 @@
# Keeps that exist so the instrumentation suite can run against the *release*
# build. They are applied to the shipping APK, which is a real trade-off worth
# stating plainly: the tested artifact is not byte-identical to an artifact
# built without them.
#
# It is the better side of the trade. The alternative is to run instrumentation
# against `debug` only, which leaves R8 — the step that has already broken this
# app twice, in ways no debug run can reproduce — entirely uncovered. What is
# kept here is a few kilobytes of framework plumbing containing no application
# logic and revealing nothing about the app.
#
# Why these keeps are needed at all: the instrumentation APK is minified in a
# separate R8 run that consumes the app's mapping file, and links against the
# app's classes at runtime. That arrangement fails in two ways.
#
# 1. The mapping file carries renames but not R8's parameter-permutation
# optimisation, so a permuted method resolves to the wrong descriptor from
# the test APK.
# 2. A class that only the test APK reaches is unreachable from the app's call
# graph, so R8 removes it from the app APK — and the test APK does not
# bundle it, because it is supposed to come from the app.
# The Kotlin runtime is where both failures land, because androidx.test is
# itself Kotlin and shares the app's copy of the stdlib. Keeping it wholesale
# rather than class by class is deliberate: each individual keep only moves the
# crash to the next facade the runner happens to touch, which is a list nobody
# can enumerate ahead of time. Three were hit before this rule replaced them:
#
# NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
# in class Lo6/k; (o6.k = kotlin.jvm.internal.Intrinsics,
# whose parameters R8 had permuted —
# every Kotlin class calls it on entry,
# so nothing ran at all)
# NoClassDefFoundError: Lkotlin/LazyKt; (androidx.test.platform.io.TestDirCalculator)
#
# Cost is roughly a megabyte of dex against a ~100 MB APK that is almost
# entirely native libraries.
-keep class kotlin.** { *; }
# `AndroidJUnitRunner.onCreate` traces its own startup. Nothing in the app
# reaches androidx.tracing, so R8 dropped it and the runner could not start:
#
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/tracing/Trace;
# at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:307)
-keep class androidx.tracing.** { *; }
# A third shape of the same problem, and the one worth understanding: the app
# only ever touches WorkManager through its implementation, so R8 vertically
# merged the abstract `androidx.work.WorkManager` into `WorkManagerImpl` and the
# abstract type stopped existing. It is absent from mapping.txt entirely rather
# than renamed. The download tests declare fields of the public types, so JUnit's
# field scan could not even construct the test class:
#
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/work/WorkManager;
# at org.junit.runners.model.TestClass.getSortedDeclaredFields(TestClass.java:77)
#
# Kept package-wide because the same merge can happen to any of the public API
# types the tests name (WorkInfo, Data, Constraints), and finding out one crash
# at a time costs a four-minute device round trip each.
-keep class androidx.work.** { *; }
# Everything above is framework plumbing. The last group is app code, and it is
# the one keep in this file that costs real fidelity: R8 no longer optimises
# these classes in the shipping build, so the suite no longer proves R8 handles
# them correctly. It is a narrow loss — the surfaces R8 has actually
# broken here are the FFI ones, and those are kept for functional reasons in
# proguard-rules.pro and stay under test.
#
# The trigger was `DownloadStateStore.clear()`. It is not dead code — AppViewModel
# calls it — but it has exactly one call site, so R8 inlined it and dropped the
# method. Only a caller that resolves it by name, which is to say a test, notices:
#
# java.lang.NoSuchMethodError: No virtual method clear()V
# in class Lio/narl/protonstream/download/c;
#
# Package-wide rather than class-by-class: Kotlin's top-level functions live in
# a synthesised `...Kt` facade that no source file names, so keeping the classes
# a test imports still leaves it missing `DownloadCoordinatorKt`.
-keep class io.narl.protonstream.download.** { *; }
# Same inlining, one layer out: the `wifiOnly` setter behind the download
# constraint.
# java.lang.NoSuchMethodError: No virtual method setWifiOnly(Z)V in class Li5/a;
-keep class io.narl.protonstream.settings.SettingsStore { *; }
# The live cases drive the player directly, so the same inlining applies to the
# playback classes they name.
-keep class io.narl.protonstream.playback.** { *; }
# `runBlocking` — the live cases drive suspending bridge calls from a JUnit
# thread. The app itself never blocks a thread on a coroutine, so R8 drops the
# facade that holds it:
#
# java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/coroutines/BuildersKt;
#
# Kept narrowly rather than as `kotlinx.coroutines.**`: that library is large and
# on the app's hot paths, and pinning all of it would cost real optimisation.
-keep class kotlinx.coroutines.BuildersKt** { *; }
+16
View File
@@ -7,3 +7,19 @@
-keepclasseswithmembernames class * { -keepclasseswithmembernames class * {
native <methods>; native <methods>;
} }
# UniFFI records are Kotlin data classes whose generic signatures and
# annotations survive into the runtime; stripping them changes how the bindings
# lift and lower structured types.
-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod
# JNA dispatches into callbacks reflectively by method name.
-keepclassmembers class * implements com.sun.jna.Callback {
<methods>;
}
# The bridge calls back into these from Rust. Nothing in the Kotlin call graph
# reaches their overrides, so R8 is free to rename or drop them.
-keep class io.narl.protonstream.native.KeystoreSecretStore { *; }
-keep class * implements uniffi.pstr_android.AndroidSecretStore { *; }
-keep class * implements uniffi.pstr_android.DownloadObserver { *; }
+11
View File
@@ -0,0 +1,11 @@
# Applied only to the instrumentation APK when androidTest runs against a
# minified build (ANDROID_TEST_BUILD_TYPE=release). The tests exist to prove the
# *app's* R8 configuration is right, so nothing here may relax the app's rules —
# it only keeps the test harness itself intact.
-dontobfuscate
-keep class io.narl.protonstream.** { *; }
-keep class androidx.test.** { *; }
-dontwarn androidx.test.**
-keepclasseswithmembers class * {
@org.junit.Test <methods>;
}
@@ -0,0 +1,129 @@
package io.narl.protonstream.download
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.Configuration
import androidx.work.NetworkType
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import io.narl.protonstream.settings.SettingsStore
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* What actually reaches WorkManager. The unit tests cover the pure helpers; this
* covers the enqueue itself — the input data (which is persisted unencrypted, so
* it must carry no secret), the constraints, and the unique-work identity that
* pause and resume depend on.
*/
@RunWith(AndroidJUnit4::class)
class DownloadCoordinatorInstrumentedTest {
private lateinit var context: Context
private lateinit var workManager: WorkManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
WorkManagerTestInitHelper.initializeTestWorkManager(
context,
Configuration.Builder().setExecutor(SynchronousExecutor()).build(),
)
workManager = WorkManager.getInstance(context)
workManager.cancelAllWork().result.get()
DownloadStateStore(context).clear()
}
private fun download(linkId: String = "link-1", shareId: String = "share-1") = RetainedDownload(
shareId = shareId,
volumeId = "volume-1",
linkId = linkId,
label = "S01E01.mkv",
)
private fun infoFor(download: RetainedDownload): WorkInfo? =
workManager.getWorkInfosForUniqueWork(
DownloadCoordinator.workName(download.shareId, download.linkId),
).get().firstOrNull()
@Test
fun resumingEnqueuesUniqueWorkAndRetainsAQueuedRecord() {
val download = download()
DownloadCoordinator.resume(context, download)
assertNotNull(infoFor(download))
assertEquals(
RetainedDownload.STATUS_QUEUED,
DownloadStateStore(context).get("share-1", "link-1")?.status,
)
}
/**
* Tags are persisted in `androidx.work.workdb` unencrypted and are what the
* UI queries and what cancel-by-share matches on. Pinning the exact set is
* what makes an accidental addition — a fragment, a URL — visible in review.
*/
@Test
fun theWorkCarriesExactlyTheExpectedTags() {
val download = download()
DownloadCoordinator.resume(context, download)
val tags = infoFor(download)?.tags.orEmpty()
assertTrue(DownloadCoordinator.TAG in tags)
assertTrue(DownloadCoordinator.shareTag("share-1") in tags)
assertTrue("${DownloadCoordinator.EPISODE_TAG}S01E01.mkv" in tags)
// The remaining tag is WorkManager's own worker class name.
assertEquals(4, tags.size)
}
@Test
fun theWifiOnlySettingReachesTheConstraint() {
// Documents B29: the constraint is read at enqueue time, so work already
// queued keeps whatever was set when it was queued. When B29 is fixed,
// this test grows a case for flipping the toggle after enqueue.
SettingsStore(context).wifiOnly = true
assertEquals(NetworkType.UNMETERED, requiredNetworkType(SettingsStore(context).wifiOnly))
SettingsStore(context).wifiOnly = false
assertEquals(NetworkType.CONNECTED, requiredNetworkType(SettingsStore(context).wifiOnly))
}
@Test
fun pauseCancelsTheWorkAndRecordsThePausedStatus() {
val download = download()
DownloadCoordinator.resume(context, download)
DownloadCoordinator.pause(context, download)
assertTrue(infoFor(download)?.state?.isFinished ?: false)
assertEquals(
RetainedDownload.STATUS_PAUSED,
DownloadStateStore(context).get("share-1", "link-1")?.status,
)
}
@Test
fun cancellingOneShareLeavesAnotherSharesWorkAlone() {
val first = download(linkId = "a", shareId = "share-1")
val second = download(linkId = "b", shareId = "share-2")
DownloadCoordinator.resume(context, first)
DownloadCoordinator.resume(context, second)
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag("share-1")).result.get()
assertTrue(infoFor(first)?.state?.isFinished ?: false)
assertEquals(WorkInfo.State.ENQUEUED, infoFor(second)?.state)
}
@Test
fun twoEpisodesOfOneShareEnqueueSeparately() {
DownloadCoordinator.resume(context, download(linkId = "a"))
DownloadCoordinator.resume(context, download(linkId = "b"))
assertEquals(2, workManager.getWorkInfosByTag(DownloadCoordinator.TAG).get().size)
}
}
@@ -0,0 +1,274 @@
package io.narl.protonstream.live
import android.graphics.SurfaceTexture
import android.view.Surface
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.narl.protonstream.playback.NativeMpvHost
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths
import uniffi.pstr_android.AndroidSecretStore
import uniffi.pstr_android.DownloadObserver
import uniffi.pstr_android.EpisodeRecord
/**
* The cases `docs/ANDROID.md` requires that cannot be answered without a real
* share: that a link resolves into a playable catalog, that libmpv actually
* decodes the decrypted stream, that a cancelled download leaves a resumable
* `.part`, and that a resume position survives a restart.
*
* The link is an instrumentation argument rather than a constant or a file, so
* the secret never enters the repository:
*
* ```
* -Pandroid.testInstrumentationRunnerArguments.pstr.shareUrl=...
* ```
*
* Without it every test here is skipped by assumption, and
* `scripts/android-acceptance.sh` reports the matrix as incomplete.
*
* Read-only against the share by construction: nothing here uploads, renames or
* deletes anything remote. It writes only to this test's own temporary
* directories.
*/
@RunWith(AndroidJUnit4::class)
class LiveShareTest {
@Test
fun theShareResolvesIntoAPlayableCatalog() {
val episodes = episodes()
assertTrue("the crawl found no playable file in the share", episodes.isNotEmpty())
// A file with no size cannot be streamed: the block layer needs the
// length to place block boundaries at all.
val sized = episodes.filter { (it.size ?: 0uL) > 0uL }
assertTrue("no episode in the share reported a size", sized.isNotEmpty())
}
@Test
fun openingAnEpisodeYieldsASeekableRevision() {
val episode = playable()
val stream = runBlocking {
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
}
try {
assertTrue("the opened revision has no length", stream.size() > 0uL)
// Without a revision id the block cache cannot tell one revision of
// a file from another, and a stale block would be served as fresh.
assertTrue("the opened revision has no id", stream.revisionId().isNotBlank())
} finally {
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
}
}
/**
* The whole pipeline in one assertion: Proton block storage, decryption,
* the Rust stream C ABI, the JNI adapter and libmpv's demuxer. If the
* position advances, real decrypted bytes reached the decoder.
*/
@Test
fun libmpvDecodesTheDecryptedStream() {
val episode = playable()
val texture = SurfaceTexture(0).apply { setDefaultBufferSize(1280, 720) }
val surface = Surface(texture)
val host = NativeMpvHost()
val key = "${episode.shareId}:${episode.linkId}"
try {
host.attachSurface(surface)
val stream = runBlocking {
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
}
runBlocking { host.play(key, stream.nativeHandle(), stream.size()) }
host.setMuted(true)
host.setPaused(false)
val playing = waitFor(PLAYBACK_TIMEOUT_MS) { host.state.value.position > 0.0 }
assertTrue(
"libmpv never advanced past zero: ${host.state.value}",
playing,
)
// One host serves every episode in turn, so a reading that does not
// name this one would resume the wrong file.
assertEquals(key, host.state.value.media)
assertTrue("libmpv reported no duration", host.state.value.duration > 0.0)
} finally {
host.stop()
host.detachSurface()
host.close()
surface.release()
texture.release()
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
}
}
/**
* Cancellation is polled between whole Proton blocks precisely so the bytes
* already written stay usable. This checks the retained `.part` is counted
* as partial rather than as a finished file, and that resuming continues
* from it instead of starting over.
*/
@Test
fun aCancelledDownloadLeavesAResumablePartFile() {
val episode = playable()
val firstRun = CancelAfter(bytes = RESUME_THRESHOLD_BYTES)
runCatching {
runBlocking {
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, firstRun)
}
}
assumeTrue(
"the episode finished before cancellation could be observed; it is too small for this case",
!firstRun.completed,
)
assertTrue("the download reported no progress at all", firstRun.observed > 0L)
val retained = engine.storageUsage()
assertTrue("a cancelled download retained no bytes", retained.partialBytes > 0uL)
assertEquals("a cancelled download counted as a finished file", 0uL, retained.offlineCount)
// Resuming must pick up where the .part ended rather than at zero.
val secondRun = CancelAfter(bytes = retained.partialBytes.toLong() + RESUME_THRESHOLD_BYTES)
runCatching {
runBlocking {
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, secondRun)
}
}
assertTrue(
"resuming restarted from zero: first reading was ${secondRun.firstReading}, " +
"but ${retained.partialBytes} bytes were already retained",
secondRun.firstReading >= retained.partialBytes.toLong(),
)
runBlocking { engine.removeOfflineEpisode(episode.shareId, episode.linkId) }
assertEquals(
"removing the episode left partial bytes behind",
0uL,
engine.storageUsage().partialBytes,
)
}
/** What the viewer sees as "continue watching" after the app is killed. */
@Test
fun aResumePositionSurvivesReopeningTheCatalog() {
val episode = playable()
engine.saveWatchState(episode.shareId, episode.linkId, RESUME_POSITION, null, false)
val reopened = AndroidEngine(paths, secrets)
val state = reopened.watchState(episode.shareId, episode.linkId)
assertNotNull("the resume position did not survive a reopen", state)
assertEquals(RESUME_POSITION, state!!.positionSecs, 0.001)
assertEquals(false, state.watched)
// The catalog view is what the UI reads, so it has to agree.
val listed = reopened.library(null)
.flatMap { it.seasons }
.flatMap { it.episodes }
.first { it.linkId == episode.linkId }
assertEquals(RESUME_POSITION, listed.resumeAt ?: -1.0, 0.001)
}
/** Cancels once the download has written enough to prove a resume. */
private class CancelAfter(private val bytes: Long) : DownloadObserver {
private val cancelled = AtomicBoolean(false)
private val progress = AtomicLong(0)
private val first = AtomicLong(-1)
val observed: Long get() = progress.get()
val firstReading: Long get() = first.get()
val completed: Boolean get() = !cancelled.get()
override fun onProgress(downloaded: ULong, total: ULong) {
first.compareAndSet(-1, downloaded.toLong())
progress.set(downloaded.toLong())
if (downloaded.toLong() >= bytes) cancelled.set(true)
}
override fun isCancelled(): Boolean = cancelled.get()
}
private fun episodes(): List<EpisodeRecord> =
engine.library(null).flatMap { it.seasons }.flatMap { it.episodes }
private fun playable(): EpisodeRecord =
episodes().firstOrNull { (it.size ?: 0uL) > 0uL }
?: throw AssertionError("the share has no sized file to play")
private fun waitFor(timeoutMs: Long, condition: () -> Boolean): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return true
Thread.sleep(200)
}
return false
}
/** Stands in for Keystore, so a bridge failure cannot be blamed on crypto. */
private class MemorySecretStore : AndroidSecretStore {
private val entries = ConcurrentHashMap<String, String>()
override fun set(key: String, value: String) {
entries[key] = value
}
override fun get(key: String): String? = entries[key]
override fun delete(key: String) {
entries.remove(key)
}
}
companion object {
private const val SHARE_URL_ARGUMENT = "pstr.shareUrl"
private const val SHARE_PASSWORD_ARGUMENT = "pstr.sharePassword"
/** Long enough for a cold connection, a crawl and a first block read. */
private const val PLAYBACK_TIMEOUT_MS = 60_000L
/** Enough written bytes to prove a resume, small enough to stay quick. */
private const val RESUME_THRESHOLD_BYTES = 4L * 1024 * 1024
private const val RESUME_POSITION = 137.5
private lateinit var paths: AndroidPaths
private lateinit var secrets: AndroidSecretStore
private lateinit var engine: AndroidEngine
@BeforeClass
@JvmStatic
fun openTheShare() {
System.loadLibrary("pstr_android")
val arguments = InstrumentationRegistry.getArguments()
val url = arguments.getString(SHARE_URL_ARGUMENT)
assumeTrue(
"set -Pandroid.testInstrumentationRunnerArguments.$SHARE_URL_ARGUMENT to run the live cases",
!url.isNullOrBlank(),
)
val password = arguments.getString(SHARE_PASSWORD_ARGUMENT)?.takeIf { it.isNotBlank() }
val root = File.createTempFile("pstr-live", "").let {
it.delete()
it.mkdirs()
it
}
paths = AndroidPaths(
File(root, "config").absolutePath,
File(root, "data").absolutePath,
File(root, "cache").absolutePath,
)
secrets = MemorySecretStore()
engine = AndroidEngine(paths, secrets)
engine.addShare("acceptance", url!!, password)
runBlocking { engine.crawl(null) }
}
}
}
@@ -0,0 +1,155 @@
package io.narl.protonstream.native
import android.content.Context
import android.util.Base64
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.security.KeyStore
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.pstr_android.BridgeException
/**
* Runs on a device because Android Keystore has no host implementation — which
* is why nothing pinned this before. The share fragment and any custom link
* password are the only user secrets the app holds, and losing the key makes
* every share unopenable, so the failure modes matter as much as the happy path.
*/
@RunWith(AndroidJUnit4::class)
class KeystoreSecretStoreTest {
private lateinit var context: Context
private lateinit var store: KeystoreSecretStore
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
clearPreferences()
store = KeystoreSecretStore(context)
}
@After
fun tearDown() {
clearPreferences()
}
@Test
fun aSecretSurvivesARoundTrip() {
store.set("share-1", "correct horse battery staple")
assertEquals("correct horse battery staple", store.get("share-1"))
}
@Test
fun anAbsentSecretIsNullRatherThanEmpty() {
assertNull(store.get("never-written"))
}
@Test
fun deleteRemovesTheSecret() {
store.set("share-1", "value")
store.delete("share-1")
assertNull(store.get("share-1"))
}
@Test
fun aUnicodeSecretIsNotMangled() {
val value = "パスワード・🎬・ü"
store.set("share-1", value)
assertEquals(value, store.get("share-1"))
}
/**
* GCM is catastrophic under IV reuse: two secrets encrypted with the same
* key and IV leak the XOR of their plaintexts. `Cipher.init(ENCRYPT_MODE)`
* is what generates a fresh one, so this pins that the code never pins it.
*/
@Test
fun everyWriteUsesAFreshInitialisationVector() {
store.set("share-1", "same plaintext")
val first = rawPayload("share-1")
store.set("share-1", "same plaintext")
val second = rawPayload("share-1")
val firstIv = first.copyOfRange(0, IV_SIZE)
val secondIv = second.copyOfRange(0, IV_SIZE)
assertNotEquals(Base64.encodeToString(firstIv, Base64.NO_WRAP), Base64.encodeToString(secondIv, Base64.NO_WRAP))
}
@Test
fun theStoredFormIsNotThePlaintext() {
store.set("share-1", "correct horse battery staple")
val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString("share-1", null)
assertTrue(stored != null && !stored.contains("correct horse"))
}
/**
* A tampered payload must not decrypt, and must fail as a [BridgeException].
* An `AEADBadTagException` crossing the FFI has no mapping on the Rust side,
* and an unmapped callback exception panics the calling thread (B18).
*/
@Test
fun aTamperedPayloadFailsToDecrypt() {
store.set("share-1", "value")
val payload = rawPayload("share-1")
payload[payload.size - 1] = (payload[payload.size - 1].toInt() xor 0x01).toByte()
writeRawPayload("share-1", payload)
assertThrows(BridgeException::class.java) { store.get("share-1") }
}
@Test
fun aTruncatedPayloadIsRejectedBeforeDecryption() {
store.set("share-1", "value")
writeRawPayload("share-1", rawPayload("share-1").copyOfRange(0, IV_SIZE))
assertThrows(BridgeException::class.java) { store.get("share-1") }
}
/**
* The device-side half of B18: this is what a lockscreen change or a device
* restore does to the key. It must fail the read rather than the process,
* and the store must still accept a re-entered secret afterwards — which is
* what the shares screen's "Re-enter link" depends on.
*/
@Test
fun aLostKeyFailsTheReadRatherThanTheProcess() {
store.set("share-1", "value")
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
assertThrows(BridgeException::class.java) { store.get("share-1") }
// The store must still be usable for a re-entered secret.
store.set("share-1", "re-entered")
assertEquals("re-entered", store.get("share-1"))
}
private fun rawPayload(key: String): ByteArray {
val encoded = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString(key, null) ?: error("no payload for $key")
return Base64.decode(encoded, Base64.NO_WRAP)
}
private fun writeRawPayload(key: String, payload: ByteArray) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString(key, Base64.encodeToString(payload, Base64.NO_WRAP))
.commit()
}
private fun clearPreferences() {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
}
private companion object {
const val PREFERENCES = "proton_stream_secrets"
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
const val IV_SIZE = 12
}
}
@@ -0,0 +1,117 @@
package io.narl.protonstream.native
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths
import uniffi.pstr_android.AndroidSecretStore
import uniffi.pstr_android.chapterPlan
import uniffi.pstr_android.skipOffer
/**
* The first thing that crosses the UniFFI boundary on a real device. It is
* deliberately offline: it proves the ABI, the generated bindings, the callback
* interface and the SQLite open all work, without needing a share.
*
* If this fails and the Rust workspace tests pass, the fault is in packaging —
* a missing ABI in `jniLibs`, a stale binding, or an R8 rule (run it with
* `ANDROID_TEST_BUILD_TYPE=release` to check the shipped configuration).
*/
@RunWith(AndroidJUnit4::class)
class UniFfiBoundaryTest {
/** Stands in for Keystore so a bridge failure cannot be blamed on crypto. */
private class MemorySecretStore : AndroidSecretStore {
val entries = ConcurrentHashMap<String, String>()
var reads = 0
private set
override fun set(key: String, value: String) {
entries[key] = value
}
override fun get(key: String): String? {
reads += 1
return entries[key]
}
override fun delete(key: String) {
entries.remove(key)
}
}
private fun engine(secrets: AndroidSecretStore = MemorySecretStore()): AndroidEngine {
val root = File.createTempFile("pstr-bridge", "").let {
it.delete()
it.mkdirs()
it
}
return AndroidEngine(
AndroidPaths(
File(root, "config").absolutePath,
File(root, "data").absolutePath,
File(root, "cache").absolutePath,
),
secrets,
)
}
@Test
fun theEngineOpensAgainstEmptyDirectories() {
val engine = engine()
assertTrue(engine.shares().isEmpty())
assertTrue(engine.library(null).isEmpty())
}
@Test
fun storageUsageReportsZeroOnAFreshInstall() {
val usage = engine().storageUsage()
assertNotNull(usage)
assertEquals(0UL, usage.offlineBytes)
assertEquals(0UL, usage.partialBytes)
assertEquals(0UL, usage.offlineCount)
}
@Test
fun metadataSettingsAreReadableAndDefaultToDisabled() {
// Enrichment is opt-in; a default of enabled would send titles to a
// third party before the viewer has agreed to it.
assertEquals(false, engine().metadataSettings().enabled)
}
@Test
fun aSecondEngineReopensTheSameCatalog() {
val secrets = MemorySecretStore()
engine(secrets).shares()
// Reopening is the process-restart path; a schema or lock fault shows up
// here rather than on a user's device.
assertTrue(engine(secrets).shares().isEmpty())
}
/**
* Chapter classification is shared Rust that desktop already pins
* (`docs/BUGS.md` B12). This runs the same code through the bindings, so a
* lowering fault in the record types cannot pass unnoticed.
*/
@Test
fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0)
assertNotNull(plan)
assertNull(skipOffer(plan, 12.0))
}
companion object {
@BeforeClass
@JvmStatic
fun loadLibrary() {
System.loadLibrary("pstr_android")
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
+7 -1
View File
@@ -12,6 +12,12 @@ add_library(pstr_mpv SHARED pstr_mpv.cpp)
add_library(mpv SHARED IMPORTED) add_library(mpv SHARED IMPORTED)
set_target_properties(mpv PROPERTIES set_target_properties(mpv PROPERTIES
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so") IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
# libmpv links FFmpeg dynamically and does not re-export it, so registering the
# JavaVM that MediaCodec needs means linking libavcodec ourselves. It is already
# packaged in the same ABI directory as part of libmpv's dependency closure.
add_library(avcodec SHARED IMPORTED)
set_target_properties(avcodec PROPERTIES
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libavcodec.so")
target_compile_features(pstr_mpv PRIVATE cxx_std_20) target_compile_features(pstr_mpv PRIVATE cxx_std_20)
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror) target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
@@ -20,4 +26,4 @@ target_include_directories(pstr_mpv PRIVATE "${PSTR_MPV_ROOT}/include")
# packaged into the same ABI directory in the APK. # packaged into the same ABI directory in the APK.
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}") target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so") target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
target_link_libraries(pstr_mpv PRIVATE mpv android EGL log) target_link_libraries(pstr_mpv PRIVATE mpv avcodec android EGL log)
+380 -25
View File
@@ -1,6 +1,7 @@
#include "pstr_mpv.h" #include "pstr_mpv.h"
#include <EGL/egl.h> #include <EGL/egl.h>
#include <android/log.h>
#include <android/native_window.h> #include <android/native_window.h>
#include <android/native_window_jni.h> #include <android/native_window_jni.h>
#include <jni.h> #include <jni.h>
@@ -8,6 +9,10 @@
#include <mpv/render_gl.h> #include <mpv/render_gl.h>
#include <mpv/stream_cb.h> #include <mpv/stream_cb.h>
extern "C" {
#include <libavcodec/jni.h>
}
#include <atomic> #include <atomic>
#include <algorithm> #include <algorithm>
#include <charconv> #include <charconv>
@@ -86,16 +91,32 @@ int64_t stream_size(void *opaque) noexcept {
}); });
} }
/// mpv wants its request slot back — a seek, or a teardown.
///
/// The flag alone only stops the *next* read; the one already parked in a 4 MiB
/// block fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
/// out the whole fetch while joining the demuxer thread.
void stream_cancel(void *opaque) noexcept { void stream_cancel(void *opaque) noexcept {
c_boundary([=] { c_boundary([=] {
if (auto *stream = static_cast<StreamCookie *>(opaque)) { if (auto *stream = static_cast<StreamCookie *>(opaque)) {
stream->cancelled.store(true, std::memory_order_relaxed); stream->cancelled.store(true, std::memory_order_relaxed);
pstr_android_stream_cancel(stream->handle);
} }
}); });
} }
/// Releases the Rust stream behind the file mpv has finished with.
///
/// Ownership sits here rather than in Kotlin because this is the one moment
/// that is *defined* to be after mpv's last read: `loadfile` is asynchronous, so
/// a Kotlin-side release issued when the next load returns can land while the
/// outgoing demuxer is still reading — and, if the release names the handle the
/// registry has already swapped, takes the incoming episode's stream with it.
void stream_close(void *opaque) noexcept { void stream_close(void *opaque) noexcept {
c_boundary([=] { delete static_cast<StreamCookie *>(opaque); }); c_boundary([=] {
std::unique_ptr<StreamCookie> cookie(static_cast<StreamCookie *>(opaque));
if (cookie) pstr_android_stream_release(cookie->handle);
});
} }
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept { int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
@@ -130,6 +151,29 @@ void *resolve_gl(void *, const char *name) noexcept {
}); });
} }
/// Why the open file ended, mirroring `pstr_player::EndReason` so that the two
/// clients agree on what "finished" means. Only [Eof] is an episode the viewer
/// watched to the end; the rest are a stop, a shutdown or a failure, and none of
/// them may mark anything watched or advance to the next episode.
enum class EndReason : int {
None = 0,
Eof = 1,
Stopped = 2,
Quit = 3,
Failed = 4,
Other = 5,
};
EndReason end_reason_of(int reason) {
switch (reason) {
case MPV_END_FILE_REASON_EOF: return EndReason::Eof;
case MPV_END_FILE_REASON_STOP: return EndReason::Stopped;
case MPV_END_FILE_REASON_QUIT: return EndReason::Quit;
case MPV_END_FILE_REASON_ERROR: return EndReason::Failed;
default: return EndReason::Other;
}
}
struct PlaybackState { struct PlaybackState {
double position = 0.0; double position = 0.0;
double duration = 0.0; double duration = 0.0;
@@ -137,6 +181,30 @@ struct PlaybackState {
bool paused = true; bool paused = true;
bool muted = false; bool muted = false;
bool ended = false; bool ended = false;
EndReason end_reason = EndReason::None;
/// mpv has run out of buffered data and stopped to refill. Distinct from
/// [paused], which is the viewer's doing: a frozen picture that nothing
/// explains is indistinguishable from a hung app.
bool buffering = false;
/// How full the demuxer cache is while [buffering], 0–100.
double cache_percent = 0.0;
/// Between a seek being issued and playback actually resuming. The seek bar
/// has moved but the picture has not.
bool seeking = false;
/// True between `loadfile` being issued and mpv acknowledging it with
/// `START_FILE`. Everything mpv reports in that window still describes the
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
/// and must not be attributed to the file being loaded.
bool loading = false;
/// The picture's display size, once mpv has decoded enough to know it.
/// Zero until then, and the aspect Picture-in-Picture is given.
double video_width = 0.0;
double video_height = 0.0;
/// Which loaded file this state describes. Bumped by every load, so a
/// reader can tell "episode two, second zero" from a leftover reading of
/// episode one — which is the difference between resuming an episode where
/// the viewer left it and resuming it where the *previous* one ended.
int64_t generation = 0;
}; };
class Player { class Player {
@@ -148,7 +216,15 @@ class Player {
option("vo", "libmpv"); option("vo", "libmpv");
option("force-window", "no"); option("force-window", "no");
option("video-timing-offset", "0"); option("video-timing-offset", "0");
option("hwdec", "auto-safe"); // `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
// an `ANativeWindow` the decoder is handed at init, and this player has
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
// and the SurfaceView — when there is one at all — attaches later than
// the load. `auto-safe` picks them anyway and every file opens with
// `hevc_mediacodec: Both surface and native_window are NULL` before
// falling back. The copy variants decode in hardware and hand back
// ordinary frames, which is what this render path can actually use.
option("hwdec", "auto-copy-safe");
option("cache", "yes"); option("cache", "yes");
option("cache-secs", "30"); option("cache-secs", "30");
option("demuxer-readahead-secs", "30"); option("demuxer-readahead-secs", "30");
@@ -165,6 +241,13 @@ class Player {
observe("pause", 3, MPV_FORMAT_FLAG); observe("pause", 3, MPV_FORMAT_FLAG);
observe("volume", 4, MPV_FORMAT_DOUBLE); observe("volume", 4, MPV_FORMAT_DOUBLE);
observe("mute", 5, MPV_FORMAT_FLAG); observe("mute", 5, MPV_FORMAT_FLAG);
observe("video-params/dw", 6, MPV_FORMAT_INT64);
observe("video-params/dh", 7, MPV_FORMAT_INT64);
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
// Errors mpv reports about the file itself, rather than about the
// request that opened it. Without these a failed demux is silent.
mpv_request_log_messages(mpv_, "error");
events_ = std::thread([this] { event_loop(); }); events_ = std::thread([this] { event_loop(); });
renderer_ = std::thread([this] { render_loop(); }); renderer_ = std::thread([this] { render_loop(); });
} }
@@ -213,23 +296,60 @@ class Player {
bool load(uint64_t handle, double start, const std::string &audio, bool load(uint64_t handle, double start, const std::string &audio,
const std::string &subtitle, bool subtitles) { const std::string &subtitle, bool subtitles) {
if (!mpv_) return false; if (!mpv_) return false;
{ // No wait for a render context. It is created with the render thread and
std::unique_lock lock(render_mutex_); // needs no window, and a load that cannot get one plays audio anyway —
if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false; // waiting for a surface here is what made background playback fail after
} // a five-second stall.
if (!audio.empty()) set_string("alang", audio); if (!audio.empty()) set_string("alang", audio);
if (!subtitle.empty()) set_string("slang", subtitle); if (!subtitle.empty()) set_string("slang", subtitle);
set_string("sid", subtitles ? "auto" : "no"); set_string("sid", subtitles ? "auto" : "no");
pending_start_.store(std::max(0.0, start)); pending_start_.store(std::max(0.0, start));
// Retire the outgoing file's position, duration and end flag *before*
// the new one loads. mpv only republishes them once it has demuxed
// enough of the stream, and until then every reader would otherwise see
// the last episode's clock under the new episode's name.
{
std::lock_guard lock(state_mutex_);
const double volume = state_.volume;
const bool muted = state_.muted;
// `pause` is a property of the core, not of the file: mpv carries it
// across `loadfile` unchanged and therefore never re-publishes it.
// Resetting it to the struct's default would leave every reader
// believing a playing file is paused for as long as it is open, and
// every "play" they then send is a no-op against a core that never
// paused — which is a play/pause button that does nothing.
const bool paused = state_.paused;
const int64_t generation = state_.generation + 1;
state_ = PlaybackState{};
state_.volume = volume;
state_.muted = muted;
state_.paused = paused;
state_.generation = generation;
state_.loading = true;
error_.clear();
}
const std::string url = "pstr://" + std::to_string(handle); const std::string url = "pstr://" + std::to_string(handle);
const char *command[] = {"loadfile", url.c_str(), nullptr}; const char *command[] = {"loadfile", url.c_str(), nullptr};
if (mpv_command(mpv_, command) < 0) return false; if (mpv_command(mpv_, command) < 0) {
// Nothing was issued, so no START_FILE will arrive to clear this.
std::lock_guard lock(state_mutex_);
state_.loading = false;
return false;
}
return true; return true;
} }
void pause(bool value) { set_flag("pause", value); } /// Written through rather than left to the observer: the transport is drawn
/// from this state, and a tap that only shows its effect on mpv's next
/// property event reads as a button that missed.
void pause(bool value) {
set_flag("pause", value);
std::lock_guard lock(state_mutex_);
state_.paused = value;
}
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); } void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); } void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
void speed(double value) { set_double("speed", std::clamp(value, 0.25, 4.0)); }
void mute(bool value) { set_flag("mute", value); } void mute(bool value) { set_flag("mute", value); }
void select_track(bool audio, int64_t id) { void select_track(bool audio, int64_t id) {
const char *property = audio ? "aid" : "sid"; const char *property = audio ? "aid" : "sid";
@@ -246,6 +366,12 @@ class Player {
return state_; return state_;
} }
/// The pending error, if any, consumed by reading it.
std::string take_error() {
std::lock_guard lock(state_mutex_);
return std::exchange(error_, std::string());
}
std::string tracks_json() const { std::string tracks_json() const {
if (!mpv_) return "[]"; if (!mpv_) return "[]";
int64_t count = 0; int64_t count = 0;
@@ -274,22 +400,115 @@ class Player {
return json + ']'; return json + ']';
} }
/// The file's chapters, read one sub-property at a time like the tracks.
/// What they *mean* is decided in Rust (`pstr_core::chapters`), so the
/// desktop and this client offer the same skips.
std::string chapters_json() const {
if (!mpv_) return "[]";
int64_t count = 0;
if (mpv_get_property(mpv_, "chapter-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
std::string json = "[";
for (int64_t i = 0; i < count; ++i) {
const std::string base = "chapter-list/" + std::to_string(i) + "/";
char *title_raw = nullptr;
double start = 0.0;
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
MpvString title(title_raw);
mpv_get_property(mpv_, (base + "time").c_str(), MPV_FORMAT_DOUBLE, &start);
if (i) json += ',';
json += "{\"index\":" + std::to_string(i) + ",\"title\":\"" + escape(title.get()) +
"\",\"start\":" + std::to_string(start) + '}';
}
return json + ']';
}
private: private:
struct MpvFree { struct MpvFree {
void operator()(char *value) const noexcept { if (value) mpv_free(value); } void operator()(char *value) const noexcept { if (value) mpv_free(value); }
}; };
using MpvString = std::unique_ptr<char, MpvFree>; using MpvString = std::unique_ptr<char, MpvFree>;
/// JSON-escape `text`, and re-encode it as Modified UTF-8.
///
/// The result of this ends up in `NewStringUTF`, which does not take UTF-8:
/// it takes Modified UTF-8, where a character outside the basic plane is a
/// surrogate *pair* of three-byte sequences rather than one four-byte
/// sequence. Handing it real UTF-8 renders an emoji in a track title as
/// mojibake, and aborts the process under CheckJNI.
///
/// Anything that is not well-formed UTF-8 is dropped rather than passed
/// through, for the same reason: a truncated sequence is what CheckJNI
/// rejects, and a track title is not worth a crash.
static std::string escape(const char *text) { static std::string escape(const char *text) {
std::string out; std::string out;
if (!text) return out; if (!text) return out;
for (const unsigned char c : std::string_view(text)) { const std::string_view input(text);
if (c == '"' || c == '\\') out += '\\'; for (size_t i = 0; i < input.size();) {
if (c >= 0x20) out += static_cast<char>(c); const auto lead = static_cast<unsigned char>(input[i]);
size_t length = 0;
uint32_t code = 0;
if (lead < 0x80) {
length = 1;
code = lead;
} else if ((lead & 0xE0) == 0xC0) {
length = 2;
code = lead & 0x1Fu;
} else if ((lead & 0xF0) == 0xE0) {
length = 3;
code = lead & 0x0Fu;
} else if ((lead & 0xF8) == 0xF0) {
length = 4;
code = lead & 0x07u;
} else {
++i; // A stray continuation or invalid lead byte.
continue;
}
if (i + length > input.size()) break;
bool valid = true;
for (size_t k = 1; k < length; ++k) {
const auto next = static_cast<unsigned char>(input[i + k]);
if ((next & 0xC0) != 0x80) {
valid = false;
break;
}
code = (code << 6) | (next & 0x3Fu);
}
if (!valid) {
++i;
continue;
}
i += length;
if (code < 0x20) continue; // Control characters, including NUL.
if (code == '"' || code == '\\') {
out += '\\';
out += static_cast<char>(code);
continue;
}
if (code < 0x80) {
out += static_cast<char>(code);
} else if (code < 0x800) {
out += static_cast<char>(0xC0 | (code >> 6));
out += static_cast<char>(0x80 | (code & 0x3F));
} else if (code < 0x10000) {
append_three(out, code);
} else {
// The surrogate pair Modified UTF-8 wants, each half written as
// its own three-byte sequence.
const uint32_t rest = code - 0x10000;
append_three(out, 0xD800 + (rest >> 10));
append_three(out, 0xDC00 + (rest & 0x3FF));
}
} }
return out; return out;
} }
static void append_three(std::string &out, uint32_t code) {
out += static_cast<char>(0xE0 | (code >> 12));
out += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
out += static_cast<char>(0x80 | (code & 0x3F));
}
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); } void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); } void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); } void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
@@ -306,25 +525,72 @@ class Player {
if (start > 0.0) set_double("time-pos", start); if (start > 0.0) set_double("time-pos", start);
} }
if (event->event_id == MPV_EVENT_END_FILE) { if (event->event_id == MPV_EVENT_END_FILE) {
const auto *end = static_cast<mpv_event_end_file *>(event->data);
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
// The end of the file `loadfile` is replacing, not of the one it
// is loading. Reporting it would hand every reader an episode
// that ended before it started.
if (state_.loading) continue;
state_.ended = true; state_.ended = true;
state_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
} else if (event->event_id == MPV_EVENT_START_FILE) { } else if (event->event_id == MPV_EVENT_START_FILE) {
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
state_.loading = false;
state_.ended = false; state_.ended = false;
state_.end_reason = EndReason::None;
} else if (event->event_id == MPV_EVENT_SEEK) {
std::lock_guard lock(state_mutex_);
state_.seeking = true;
} else if (event->event_id == MPV_EVENT_PLAYBACK_RESTART) {
// The seek is *visibly* done here, not when it was issued.
std::lock_guard lock(state_mutex_);
state_.seeking = false;
} else if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
record_log(*static_cast<mpv_event_log_message *>(event->data));
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) { } else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
update_property(*static_cast<mpv_event_property *>(event->data)); update_property(*static_cast<mpv_event_property *>(event->data));
} }
} }
} }
/// Keep the first error of the open file, not the last.
///
/// A failure cascades — one unreadable header produces a dozen follow-on
/// complaints — and the first line is the one that names the cause. Cleared
/// by [take_error], which is how the UI consumes it.
///
/// Only mpv's own errors. Everything libavcodec and libavformat complain
/// about arrives under an `ffmpeg/…` prefix, and mpv is the thing that
/// decides whether any of it mattered: a decoder that refuses to
/// initialise is an error there and a fallback here, so surfacing those
/// puts a dialog over a file that goes on to play perfectly. When one of
/// them really is fatal mpv says so itself, under a prefix of its own.
void record_log(const mpv_event_log_message &message) {
if (!message.text) return;
if (message.prefix && !std::strncmp(message.prefix, "ffmpeg", 6)) return;
std::lock_guard lock(state_mutex_);
if (!error_.empty()) return;
error_.assign(message.text);
while (!error_.empty() && (error_.back() == '\n' || error_.back() == '\r')) error_.pop_back();
}
void update_property(const mpv_event_property &property) { void update_property(const mpv_event_property &property) {
if (!property.data) return; if (!property.data) return;
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data); // `volume`, `mute` and `pause` belong to the core and carry across a
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data); // load; the rest describe the open file, and a reading of them that
else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data); // arrives mid-load is the outgoing episode's clock under the incoming
// episode's name.
if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data); else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data);
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data); else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data);
else if (state_.loading) return;
else if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
else if (!std::strcmp(property.name, "video-params/dw")) state_.video_width = static_cast<double>(*static_cast<int64_t *>(property.data));
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
} }
static void render_update(void *opaque) noexcept { static void render_update(void *opaque) noexcept {
@@ -336,6 +602,15 @@ class Player {
}); });
} }
/// Owns EGL and the mpv render context for the life of the player.
///
/// Both are set up here rather than on the first surface attach, and neither
/// depends on there being a window: a pbuffer is enough to make a context
/// current, and `vo_libmpv` only needs *some* consumer. That is what makes
/// audio playback with no surface — the screen off, the app backgrounded —
/// structurally possible; before this, `load` waited five seconds for a
/// render context that only a visible SurfaceView could ever create, and
/// then failed.
void render_loop() { void render_loop() {
EGLDisplay display = EGL_NO_DISPLAY; EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT; EGLContext context = EGL_NO_CONTEXT;
@@ -343,6 +618,7 @@ class Player {
EGLSurface pbuffer = EGL_NO_SURFACE; EGLSurface pbuffer = EGL_NO_SURFACE;
EGLConfig config = nullptr; EGLConfig config = nullptr;
ANativeWindow *window = nullptr; ANativeWindow *window = nullptr;
if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
while (running_.load()) { while (running_.load()) {
std::unique_lock lock(render_mutex_); std::unique_lock lock(render_mutex_);
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); }); render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
@@ -353,18 +629,31 @@ class Player {
if (window) ANativeWindow_release(window); if (window) ANativeWindow_release(window);
window = pending_window_; window = pending_window_;
pending_window_ = nullptr; pending_window_ = nullptr;
if (window && display == EGL_NO_DISPLAY && !initialize_egl(display, context, config, pbuffer)) { if (window && display == EGL_NO_DISPLAY) {
// EGL failed at startup and playback is audio-only. Keep the
// window rather than the surface: nothing here can retry.
ANativeWindow_release(window); ANativeWindow_release(window);
window = nullptr; window = nullptr;
} }
if (window) { if (window) {
surface = eglCreateWindowSurface(display, config, window, nullptr); surface = eglCreateWindowSurface(display, config, window, nullptr);
eglMakeCurrent(display, surface, surface, context); eglMakeCurrent(display, surface, surface, context);
initialize_renderer();
} }
} }
const bool draw = frame_ready_.exchange(false); const bool draw = frame_ready_.exchange(false);
lock.unlock(); lock.unlock();
// With no window there is still a frame to consume. `vo_libmpv`
// expects its host to keep draining, and a video thread with no
// consumer stalls — which is why video came back out of sync with
// audio after the screen had been off.
if (draw && surface == EGL_NO_SURFACE && render_) {
eglMakeCurrent(display, pbuffer, pbuffer, context);
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
int skip = 1;
mpv_render_param params[] = {{MPV_RENDER_PARAM_SKIP_RENDERING, &skip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
mpv_render_context_render(render_, params);
}
}
if (draw && surface != EGL_NO_SURFACE && render_) { if (draw && surface != EGL_NO_SURFACE && render_) {
eglMakeCurrent(display, surface, surface, context); eglMakeCurrent(display, surface, surface, context);
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) { if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
@@ -394,18 +683,40 @@ class Player {
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; } if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
} }
/// All of EGL, or none of it.
///
/// Leaving a display initialised behind a failed context is what made a
/// partial failure permanent: the retry guard is "is there a display", so a
/// half-built state reads as success forever and every later attach uses an
/// uninitialised config and `EGL_NO_CONTEXT`. Unwinding here keeps the guard
/// honest, and `eglGetError` says which step failed instead of leaving a
/// black screen with nothing in the log.
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) { bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
const auto fail = [&](const char *step) {
__android_log_print(ANDROID_LOG_ERROR, "pstr-mpv", "%s failed: 0x%04x", step, eglGetError());
if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
if (display != EGL_NO_DISPLAY) eglTerminate(display);
display = EGL_NO_DISPLAY;
context = EGL_NO_CONTEXT;
pbuffer = EGL_NO_SURFACE;
config = nullptr;
return false;
};
display = eglGetDisplay(EGL_DEFAULT_DISPLAY); display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) return false; if (display == EGL_NO_DISPLAY) return fail("eglGetDisplay");
if (!eglInitialize(display, nullptr, nullptr)) return fail("eglInitialize");
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE}; const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
EGLint count = 0; EGLint count = 0;
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false; if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return fail("eglChooseConfig");
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE}; const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes); context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
if (context == EGL_NO_CONTEXT) return false; if (context == EGL_NO_CONTEXT) return fail("eglCreateContext");
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes); pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
return pbuffer != EGL_NO_SURFACE && eglMakeCurrent(display, pbuffer, pbuffer, context); if (pbuffer == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface");
if (!eglMakeCurrent(display, pbuffer, pbuffer, context)) return fail("eglMakeCurrent");
return true;
} }
void initialize_renderer() { void initialize_renderer() {
@@ -426,6 +737,8 @@ class Player {
std::thread renderer_; std::thread renderer_;
mutable std::mutex state_mutex_; mutable std::mutex state_mutex_;
PlaybackState state_; PlaybackState state_;
/// The first error mpv logged about the open file, until it is consumed.
std::string error_;
std::mutex render_mutex_; std::mutex render_mutex_;
std::condition_variable render_cv_; std::condition_variable render_cv_;
ANativeWindow *pending_window_ = nullptr; ANativeWindow *pending_window_ = nullptr;
@@ -465,13 +778,33 @@ bool utf8(JNIEnv *env, jstring value, std::string &result) {
void reject_null_surface(JNIEnv *env) noexcept { void reject_null_surface(JNIEnv *env) noexcept {
c_boundary([=] { c_boundary([=] {
jclass type = env->FindClass("java/lang/IllegalArgumentException"); jclass type = env->FindClass("java/lang/IllegalArgumentException");
if (type) env->ThrowNew(type, "surface must not be null"); if (!type) return;
env->ThrowNew(type, "surface must not be null");
// FindClass hands back a local reference, and this is called from a
// long-lived attached thread where the frame is not popped for us.
env->DeleteLocalRef(type);
}); });
} }
} // namespace } // namespace
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jobject) noexcept { /// FFmpeg's MediaCodec decoders call into Android's Java API, and they can only
/// reach it through a JavaVM handed to them explicitly — there is no way for
/// them to discover one. Without this, every hardware decoder fails to open
/// with "hevc_mediacodec: No Java virtual machine has been registered" and
/// playback falls back to software decoding, which on a phone means a hot
/// device and dropped frames on anything above 1080p.
///
/// mpv-android's own JNI layer does this in its `create` entry point; this
/// library replaces that layer, so the registration has to happen here. The
/// loader calls this once per process before any native method, which is
/// earlier than any decoder can run.
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
av_jni_set_java_vm(vm, nullptr);
return JNI_VERSION_1_6;
}
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept {
return c_boundary<jlong>(0, [] { return c_boundary<jlong>(0, [] {
auto player = std::make_unique<Player>(); auto player = std::make_unique<Player>();
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0; return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
@@ -513,6 +846,9 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->volume(value); }); c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
} }
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSpeed(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->speed(value); });
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); }); c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
} }
@@ -527,19 +863,38 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
const auto state = from(h) ? from(h)->state() : PlaybackState{}; const auto state = from(h) ? from(h)->state() : PlaybackState{};
const jdouble values[] = {state.position, state.duration, state.volume, const jdouble values[] = {state.position, state.duration, state.volume,
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0, state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
state.ended ? 1.0 : 0.0}; state.ended ? 1.0 : 0.0,
jdoubleArray result = env->NewDoubleArray(6); static_cast<jdouble>(state.generation),
state.video_width, state.video_height,
static_cast<jdouble>(state.end_reason),
state.buffering ? 1.0 : 0.0, state.cache_percent,
state.seeking ? 1.0 : 0.0};
constexpr jsize kFields = 13;
jdoubleArray result = env->NewDoubleArray(kFields);
if (!result) return static_cast<jdoubleArray>(nullptr); if (!result) return static_cast<jdoubleArray>(nullptr);
env->SetDoubleArrayRegion(result, 0, 6, values); env->SetDoubleArrayRegion(result, 0, kFields, values);
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr); if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
return result; return result;
}); });
} }
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTakeError(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary<jstring>(nullptr, [=]() -> jstring {
auto *player = from(h);
if (!player) return nullptr;
const std::string error = player->take_error();
return error.empty() ? nullptr : string(env, error);
});
}
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept { extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary<jstring>(nullptr, [=] { return c_boundary<jstring>(nullptr, [=] {
return string(env, from(h) ? from(h)->tracks_json() : "[]"); return string(env, from(h) ? from(h)->tracks_json() : "[]");
}); });
} }
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeChapters(JNIEnv *env, jobject, jlong h) noexcept {
return c_boundary<jstring>(nullptr, [=] {
return string(env, from(h) ? from(h)->chapters_json() : "[]");
});
}
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept { extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); }); c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
} }
+3
View File
@@ -8,6 +8,9 @@ extern "C" {
#endif #endif
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length); int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
int64_t pstr_android_stream_size(uint64_t handle); int64_t pstr_android_stream_size(uint64_t handle);
/// Interrupts an in-flight `pstr_android_stream_read`. A cancel with no reader
/// waiting is dropped, so this is safe to call speculatively.
void pstr_android_stream_cancel(uint64_t handle);
void pstr_android_stream_release(uint64_t handle); void pstr_android_stream_release(uint64_t handle);
#ifdef __cplusplus #ifdef __cplusplus
} }
@@ -1,72 +1,189 @@
package io.narl.protonstream package io.narl.protonstream
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.app.PendingIntent
import android.os.Bundle
import android.os.Build
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Rational import android.util.Rational
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import io.narl.protonstream.ui.ProtonStreamApp import androidx.compose.runtime.mutableStateOf
import io.narl.protonstream.ui.theme.ProtonStreamTheme import androidx.compose.runtime.snapshotFlow
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import androidx.compose.runtime.mutableStateOf
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import uniffi.pstr_android.PaletteRecord
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val playerHost = mutableStateOf<NativeMpvHost?>(null) private val playerHost = mutableStateOf<NativeMpvHost?>(null)
private val inPictureInPicture = mutableStateOf(false)
/**
* Whether a bind is outstanding, which is what has to be unbound.
*
* Set when `bindService` is called rather than when the connection arrives:
* an activity destroyed while the bind is still pending owes the system an
* `unbindService` just the same, and `onServiceConnected` may never run.
*/
private var bound = false private var bound = false
private val playbackConnection = object : ServiceConnection { private val playbackConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
playerHost.value = (service as PlaybackService.PlaybackBinder).host playerHost.value = (service as PlaybackService.PlaybackBinder).host
bound = true
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
playerHost.value = null playerHost.value = null
bound = false
} }
} }
/**
* The transport behind the buttons Android draws over the PiP window.
*
* A broadcast rather than a service intent: PiP is one of the states in
* which a background service start is refused, and the receiver is
* registered for as long as the activity that owns the window exists.
*/
private val pictureInPictureControls = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val host = playerHost.value ?: return
when (intent?.getStringExtra(EXTRA_CONTROL)) {
CONTROL_PLAY -> host.setPaused(false)
CONTROL_PAUSE -> host.setPaused(true)
CONTROL_NEXT -> host.onSkipNext?.invoke()
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
}
}
}
private val notificationPermission = registerForActivityResult( private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ } ) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Before anything draws. The palette decides the window's own
// background and which way round the system bars' icons go, and both
// are wrong for a frame if they are set from a composition instead.
// Infallible by construction — an unreadable theme file resolves to the
// shipped default rather than throwing.
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
enableEdgeToEdge() enableEdgeToEdge()
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE) // Re-applied on every change, not just at startup: picking Latte from
// the settings page has to turn the status bar's icons dark in the same
// frame the page behind them turns light.
lifecycleScope.launch {
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
}
bound = bindService(
Intent(this, PlaybackService::class.java),
playbackConnection,
Context.BIND_AUTO_CREATE,
)
ContextCompat.registerReceiver(
this,
pictureInPictureControls,
IntentFilter(ACTION_PIP_CONTROL),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
if ( if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) { ) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
// Auto-enter has to be armed *before* the gesture that would use it:
// building the params inside onUserLeaveHint is too late for the swipe
// home that gesture navigation sends, which never calls it. Re-register
// whenever the picture's shape or the transport's does.
lifecycleScope.launch {
snapshotFlow { playerHost.value }
.flatMapLatest { host -> host?.state ?: flowOf(null) }
.map { state -> PictureInPictureShape.of(state) }
.distinctUntilChanged()
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
}
setContent { setContent {
ProtonStreamTheme { ProtonStreamTheme {
ProtonStreamApp(playerHost.value) ProtonStreamApp(playerHost.value, inPictureInPicture.value)
} }
} }
} }
/**
* The parts of the window Compose does not reach: the background behind the
* whole activity, and the polarity of the system bars' icons.
*
* The background matters for the frames before and between compositions —
* the launch frame, and a rotation. It used to be a hex literal in
* `themes.xml`, which meant every cold start showed a colour from one
* flavour regardless of which one was chosen.
*
* The icons matter because the app ships a light flavour. Pinned dark, as
* they were, Latte drew white status-bar icons onto a near-white page.
*/
private fun dressWindow(palette: PaletteRecord) {
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = palette.light
isAppearanceLightNavigationBars = palette.light
}
}
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
super.onUserLeaveHint() super.onUserLeaveHint()
// The backstop for launchers that still send this instead of arming
// auto-enter. Entering twice is not an error; not entering at all is.
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) { if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
enterPictureInPictureMode( runCatching {
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(), enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
) }
} }
} }
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
// The window is now a thumbnail with its own controls. Anything the
// player would draw over it — the transport, the title bar, the skip
// offer — is unreadable at that size and covers the picture.
inPictureInPicture.value = isInPictureInPictureMode
}
override fun onDestroy() { override fun onDestroy() {
if (bound) unbindService(playbackConnection) runCatching { unregisterReceiver(pictureInPictureControls) }
if (bound) {
unbindService(playbackConnection)
bound = false
}
super.onDestroy() super.onDestroy()
} }
@@ -80,4 +197,81 @@ class MainActivity : ComponentActivity() {
playerHost.value?.stop() playerHost.value?.stop()
} }
} }
/** Everything the PiP window's shape depends on, and nothing that polls. */
private data class PictureInPictureShape(
val active: Boolean,
val paused: Boolean,
val width: Int,
val height: Int,
) {
companion object {
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
active = state != null && state.duration > 0.0 && !state.ended,
paused = state?.paused != false,
width = state?.videoWidth?.toInt() ?: 0,
height = state?.videoHeight?.toInt() ?: 0,
)
}
}
private fun PictureInPictureShape.params(): PictureInPictureParams =
PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(width, height))
.setAutoEnterEnabled(active)
.setSeamlessResizeEnabled(false)
// The player owns the whole window, so the picture's rectangle is
// the window's. Without it the transition into PiP cross-fades from
// a screenshot instead of shrinking the video that is already there.
.setSourceRectHint(windowBounds())
.setActions(
listOf(
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
),
)
.build()
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
RemoteAction(
Icon.createWithResource(this, icon),
label,
label,
PendingIntent.getBroadcast(
this,
requestCode,
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
private companion object {
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
const val EXTRA_CONTROL = "control"
const val CONTROL_PLAY = "play"
const val CONTROL_PAUSE = "pause"
const val CONTROL_NEXT = "next"
const val CONTROL_PREVIOUS = "previous"
/**
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
* and refuses the request outright rather than clamping it — so a
* 2.40:1 scope release would take PiP away entirely.
*/
fun aspectRatio(width: Int, height: Int): Rational {
if (width <= 0 || height <= 0) return Rational(16, 9)
val ratio = width.toDouble() / height
return when {
ratio > MAX_RATIO -> Rational(239, 100)
ratio < 1 / MAX_RATIO -> Rational(100, 239)
else -> Rational(width, height)
}
}
const val MAX_RATIO = 2.39
}
} }
@@ -1,37 +1,151 @@
package io.narl.protonstream.download package io.narl.protonstream.download
import android.content.Context import android.content.Context
import android.os.StatFs
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
object DownloadCoordinator { object DownloadCoordinator {
fun enqueue(context: Context, episode: EpisodeRecord) { /**
enqueue( * Where bulk queueing runs.
context, *
RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label), * Callers are click handlers, and "Download show" on a long series is a
ExistingWorkPolicy.KEEP, * WorkManager enqueue per episode — each of which writes to its own
) * database. On the main thread that is an ANR waiting for a long enough
} * series.
*/
private val queueing = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun enqueue(context: Context, episode: EpisodeRecord) = enqueue(context, listOf(episode))
fun resume(context: Context, download: RetainedDownload) { fun resume(context: Context, download: RetainedDownload) {
enqueue(context, download, ExistingWorkPolicy.REPLACE) enqueue(context, download, ExistingWorkPolicy.REPLACE)
} }
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) { private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
DownloadStateStore(context).put( val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
download.copy(status = RetainedDownload.STATUS_QUEUED, error = null), DownloadStateStore(context).put(queued)
WorkManager.getInstance(context).enqueueUniqueWork(
workName(download.shareId, download.linkId),
policy,
request(queued, SettingsStore(context).wifiOnly),
) )
val request = OneTimeWorkRequestBuilder<OfflineDownloadWorker>() }
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
val wanted = episodes.filterNot(EpisodeRecord::offline).toList()
if (wanted.isEmpty()) return
val application = context.applicationContext
queueing.launch {
val wifiOnly = SettingsStore(application).wifiOnly
val store = DownloadStateStore(application)
val manager = WorkManager.getInstance(application)
// Space is checked once for the whole request, against its total.
// Queueing a season that cannot fit and discovering it episode by
// episode at ENOSPC is how the app fills its own database's disk.
val room = roomFor(application, wanted.sumOf { it.size?.toLong() ?: 0L })
val downloads = wanted.map { episode ->
RetainedDownload(
episode.shareId,
episode.volumeId,
episode.linkId,
episode.label,
total = episode.size?.toLong() ?: 0L,
status = if (room) RetainedDownload.STATUS_QUEUED else RetainedDownload.STATUS_FAILED,
error = if (room) null else "Not enough free space",
)
}
store.putAll(downloads)
if (!room) return@launch
downloads.forEach { download ->
manager.enqueueUniqueWork(
workName(download.shareId, download.linkId),
ExistingWorkPolicy.KEEP,
request(download, wifiOnly),
)
}
}
}
/**
* Whether [bytes] plus working room will fit where offline files are kept.
*
* `setRequiresStorageNotLow` only holds work back at Android's own low-storage
* threshold, which a single episode can cross on its own. An unknown size —
* zero — is allowed through: refusing a download because the catalog has no
* size for it would be worse than the ENOSPC it is guarding against.
*/
private fun roomFor(context: Context, bytes: Long): Boolean {
if (bytes <= 0) return true
val available = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrNull() ?: return true
return available - bytes >= STORAGE_HEADROOM
}
/**
* Re-apply the network constraint to work that is already queued.
*
* Constraints are baked in at enqueue time, so turning "Wi-Fi only" on after
* queueing a season did nothing to that season — it downloaded over cellular
* on the way out of the house, which is the one moment the setting exists
* for. Running work is left alone: WorkManager stops it on its own when the
* new constraint stops being met.
*/
fun applyNetworkPolicy(context: Context) {
val application = context.applicationContext
queueing.launch {
val wifiOnly = SettingsStore(application).wifiOnly
val manager = WorkManager.getInstance(application)
DownloadStateStore(application).records()
.filter { it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING }
.forEach { download ->
manager.enqueueUniqueWork(
workName(download.shareId, download.linkId),
ExistingWorkPolicy.REPLACE,
request(download, wifiOnly),
)
}
}
}
fun pause(context: Context, download: RetainedDownload) =
setStatus(context, download, RetainedDownload.STATUS_PAUSED)
fun cancel(context: Context, download: RetainedDownload) =
setStatus(context, download, RetainedDownload.STATUS_CANCELLED)
/**
* The status write goes before the work is cancelled, and through `update`
* rather than `put`: the running worker is writing progress into the same
* record, and a whole-record `put` from a screen's stale copy would both
* lose that progress and race the worker's own read-modify-write.
*/
private fun setStatus(context: Context, download: RetainedDownload, status: String) {
DownloadStateStore(context).update(download.shareId, download.linkId) { stored ->
(stored ?: download).copy(status = status, error = null)
}
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
}
private fun request(download: RetainedDownload, wifiOnly: Boolean): OneTimeWorkRequest =
OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
.setConstraints( .setConstraints(
Constraints.Builder().setRequiredNetworkType( Constraints.Builder()
requiredNetworkType(SettingsStore(context).wifiOnly), .setRequiredNetworkType(requiredNetworkType(wifiOnly))
).build(), // A download that fills the disk takes the app's own
// database down with it; the `.part` file makes waiting free.
.setRequiresStorageNotLow(true)
.build(),
) )
.setInputData( .setInputData(
workDataOf( workDataOf(
@@ -45,26 +159,6 @@ object DownloadCoordinator {
.addTag("$EPISODE_TAG${download.label}") .addTag("$EPISODE_TAG${download.label}")
.addTag(shareTag(download.shareId)) .addTag(shareTag(download.shareId))
.build() .build()
WorkManager.getInstance(context).enqueueUniqueWork(
workName(download.shareId, download.linkId),
policy,
request,
)
}
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
episodes.filterNot(EpisodeRecord::offline).forEach { enqueue(context, it) }
}
fun pause(context: Context, download: RetainedDownload) {
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_PAUSED, error = null))
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
}
fun cancel(context: Context, download: RetainedDownload) {
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_CANCELLED))
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
}
fun shareTag(shareId: String) = "$SHARE_TAG$shareId" fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId" fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
@@ -72,6 +166,9 @@ object DownloadCoordinator {
const val TAG = "offline-download" const val TAG = "offline-download"
const val EPISODE_TAG = "offline-episode:" const val EPISODE_TAG = "offline-episode:"
private const val SHARE_TAG = "offline-share:" private const val SHARE_TAG = "offline-share:"
/** Free space to leave behind a download, for the catalog and the caches. */
private const val STORAGE_HEADROOM = 256L * 1024 * 1024
} }
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType = internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
@@ -35,18 +35,59 @@ class DownloadStateStore(context: Context) {
fun get(shareId: String, linkId: String): RetainedDownload? = fun get(shareId: String, linkId: String): RetainedDownload? =
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() } preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
fun put(record: RetainedDownload) { fun put(record: RetainedDownload) = synchronized(WRITES) {
preferences.edit().putString(record.key, encode(record)).commit() edit { putString(record.key, encode(record)) }
} }
fun remove(shareId: String, linkId: String) { /**
preferences.edit().remove(key(shareId, linkId)).commit() * Read-modify-write one record, excluding every other writer.
*
* The worker's progress update reads the stored record to decide whether the
* viewer has paused, then writes it back. `pause()` is another writer, and
* without this a `put` landing between those two halves puts `RUNNING` back
* over `PAUSED` — after which the worker's own cancellation handler sees a
* status that is not `PAUSED` and records `CANCELLED`. The pause is simply
* lost, which is the visible bug.
*
* Returning null from [change] leaves the record alone.
*/
fun update(shareId: String, linkId: String, change: (RetainedDownload?) -> RetainedDownload?) =
synchronized(WRITES) {
change(get(shareId, linkId))?.let { edit { putString(it.key, encode(it)) } }
}
/**
* Write a whole queue in one go.
*
* Queueing a season is one call, not one per episode: `commit()` per record
* on a 200-episode series was 200 synchronous fsyncs, and the callers are
* click handlers.
*/
fun putAll(records: Collection<RetainedDownload>) = synchronized(WRITES) {
edit { records.forEach { putString(it.key, encode(it)) } }
} }
fun removeShare(shareId: String) { fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
val edit = preferences.edit() edit { remove(key(shareId, linkId)) }
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) } }
edit.commit()
/** Forget every retained download. Callers cancel the work first. */
fun clear() = synchronized(WRITES) { edit { clear() } }
fun removeShare(shareId: String) = synchronized(WRITES) {
edit { records().filter { it.shareId == shareId }.forEach { remove(it.key) } }
}
/**
* `apply()`, not `commit()`.
*
* The in-memory map is updated before this returns, so a read that follows a
* write in the same process still sees it; only the disk write is deferred,
* and losing the newest progress reading to a kill is not worth an fsync on
* whichever thread happened to be writing.
*/
private inline fun edit(block: android.content.SharedPreferences.Editor.() -> Unit) {
preferences.edit().apply(block).apply()
} }
private fun encode(record: RetainedDownload) = JSONObject() private fun encode(record: RetainedDownload) = JSONObject()
@@ -78,5 +119,11 @@ class DownloadStateStore(context: Context) {
private companion object { private companion object {
const val NAME = "offline_download_state" const val NAME = "offline_download_state"
/**
* One lock for the whole process. Instances of this class are created
* ad hoc from a `Context`, so the lock cannot live on one of them.
*/
val WRITES = Any()
} }
} }
@@ -4,6 +4,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.content.Context import android.content.Context
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.Data import androidx.work.Data
@@ -23,6 +24,7 @@ class OfflineDownloadWorker(
parameters: WorkerParameters, parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel()
setForeground(createForegroundInfo(0)) setForeground(createForegroundInfo(0))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume")) val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
@@ -35,20 +37,43 @@ class OfflineDownloadWorker(
store.put(retained) store.put(retained)
val observer = object : DownloadObserver { val observer = object : DownloadObserver {
private var reportedAt = 0L
private var reportedPercent = -1
/**
* Called once per 4 MiB block — a dozen times a second on a fast
* link, each one a WorkManager database write, a notification post
* and a preferences write. Nothing the viewer can see changes that
* often, so a report is only made when the percentage moves or a
* second has passed. The final one is never suppressed.
*/
override fun onProgress(downloaded: ULong, total: ULong) { override fun onProgress(downloaded: ULong, total: ULong) {
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt() val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
val now = SystemClock.elapsedRealtime()
val complete = total > 0UL && downloaded >= total
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
return
}
reportedAt = now
reportedPercent = percent
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong())) setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
setForegroundAsync(createForegroundInfo(percent)) setForegroundAsync(createForegroundInfo(percent))
val requested = store.get(shareId, linkId) // Under the store's lock: `pause()` is a concurrent writer, and
val requestedStatus = requested?.status // this read-modify-write used to put RUNNING back over the
retained = (requested ?: retained).copy( // viewer's PAUSED and then report the download as cancelled.
downloaded = downloaded.toLong(), store.update(shareId, linkId) { stored ->
total = total.toLong(), val current = stored ?: retained
status = if (requestedStatus == RetainedDownload.STATUS_PAUSED || current.copy(
requestedStatus == RetainedDownload.STATUS_CANCELLED downloaded = downloaded.toLong(),
) requestedStatus else RetainedDownload.STATUS_RUNNING, total = total.toLong(),
) status = when (current.status) {
store.put(retained) RetainedDownload.STATUS_PAUSED,
RetainedDownload.STATUS_CANCELLED,
-> current.status
else -> RetainedDownload.STATUS_RUNNING
},
).also { retained = it }
}
} }
override fun isCancelled(): Boolean = isStopped override fun isCancelled(): Boolean = isStopped
@@ -62,9 +87,12 @@ class OfflineDownloadWorker(
}, },
onFailure = { failure -> onFailure = { failure ->
if (isStopped) { if (isStopped) {
val current = store.get(shareId, linkId) ?: retained // A pause is also a stop. Under the lock, so the record read
if (current.status != RetainedDownload.STATUS_PAUSED) { // here is the one the write lands on.
store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED)) store.update(shareId, linkId) { stored ->
val current = stored ?: retained
current.takeIf { it.status != RetainedDownload.STATUS_PAUSED }
?.copy(status = RetainedDownload.STATUS_CANCELLED)
} }
Result.failure(error("cancelled")) Result.failure(error("cancelled"))
} else if (runAttemptCount < MAX_RETRIES) { } else if (runAttemptCount < MAX_RETRIES) {
@@ -79,11 +107,14 @@ class OfflineDownloadWorker(
) )
} }
private fun createForegroundInfo(progress: Int): ForegroundInfo { /** Once per run, rather than once per 4 MiB block. */
val manager = applicationContext.getSystemService(NotificationManager::class.java) private fun createNotificationChannel() {
manager.createNotificationChannel( applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW), NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
) )
}
private fun createForegroundInfo(progress: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline") .setContentTitle("Making episode available offline")
@@ -109,6 +140,9 @@ class OfflineDownloadWorker(
const val KEY_TOTAL = "total" const val KEY_TOTAL = "total"
private const val MAX_RETRIES = 3 private const val MAX_RETRIES = 3
private const val CHANNEL_ID = "offline-downloads" private const val CHANNEL_ID = "offline-downloads"
/** Shortest gap between two identical-looking progress reports. */
private const val PROGRESS_INTERVAL_MS = 1_000L
} }
} }
@@ -2,6 +2,7 @@ package io.narl.protonstream.native
import android.content.Context import android.content.Context
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import java.security.KeyStore import java.security.KeyStore
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import uniffi.pstr_android.AndroidSecretStore import uniffi.pstr_android.AndroidSecretStore
import uniffi.pstr_android.BridgeException
/** Encrypts share fragments and passwords before they reach app preferences. */ /**
* Encrypts share fragments and passwords before they reach app preferences.
*
* Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
* the FFI have no mapping on the Rust side, and an unmapped callback exception
* is a panic on whichever thread was calling — including a tokio worker holding
* a download. What the caller does about it is a separate question; being told
* at all is this class's part.
*/
class KeystoreSecretStore(context: Context) : AndroidSecretStore { class KeystoreSecretStore(context: Context) : AndroidSecretStore {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
override fun set(key: String, value: String) { override fun set(key: String, value: String) = bridging("store credential") {
val cipher = Cipher.getInstance(TRANSFORMATION) val payload = try {
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey()) seal(value)
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) } catch (invalidated: KeyPermanentlyInvalidatedException) {
val payload = cipher.iv + encrypted // A lockscreen change or a device restore retires the key. Nothing
// it sealed can be read again — and nothing new can be sealed with
// it either, so re-entering a link would fail here rather than
// restoring access. Everything the old key held is already lost, so
// replacing it costs nothing that is not gone.
keyStore.deleteEntry(KEY_ALIAS)
seal(value)
}
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit()) check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
} }
override fun get(key: String): String? { private fun seal(value: String): ByteArray {
val encoded = preferences.getString(key, null) ?: return null val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
}
override fun get(key: String): String? = bridging("read credential") {
val encoded = preferences.getString(key, null) ?: return@bridging null
val payload = Base64.decode(encoded, Base64.NO_WRAP) val payload = Base64.decode(encoded, Base64.NO_WRAP)
require(payload.size > IV_SIZE) { "Invalid encrypted credential" } require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
val cipher = Cipher.getInstance(TRANSFORMATION) val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
encryptionKey(), encryptionKey(),
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)), GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
) )
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8) cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
} }
override fun delete(key: String) { override fun delete(key: String) = bridging("delete credential") {
check(preferences.edit().remove(key).commit()) check(preferences.edit().remove(key).commit())
} }
private fun <T> bridging(action: String, block: () -> T): T = try {
block()
} catch (error: BridgeException) {
throw error
} catch (error: Exception) {
// The message, never the stack trace: this reaches a snackbar, and the
// trace is both unreadable there and a description of app internals.
throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
}
private fun encryptionKey(): SecretKey { private fun encryptionKey(): SecretKey {
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
@@ -6,20 +6,30 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidPaths import uniffi.pstr_android.AndroidPaths
import uniffi.pstr_android.PaletteRecord
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */ /** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
object NativeRuntime { object NativeRuntime {
private lateinit var appContext: Context private lateinit var appContext: Context
@Volatile private var instance: AndroidEngine? = null @Volatile private var instance: AndroidEngine? = null
/**
* Whether Android's certificate verifier came up.
*
* False means every HTTPS request will fail validation, which otherwise
* surfaces much later as an unexplained certificate error against a share.
*/
@Volatile var tlsReady: Boolean = false
private set
fun initialize(context: Context) { fun initialize(context: Context) {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
initTls(context) tlsReady = initTls(context)
appContext = context.applicationContext appContext = context.applicationContext
} }
/** Initializes Android's certificate verifier before Proton networking. */ /** Initializes Android's certificate verifier before Proton networking. */
private external fun initTls(context: Context) private external fun initTls(context: Context): Boolean
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) { suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
instance ?: synchronized(this@NativeRuntime) { instance ?: synchronized(this@NativeRuntime) {
@@ -27,14 +37,24 @@ object NativeRuntime {
} }
} }
private fun createEngine(): AndroidEngine { /**
* The stored palette, without building the engine.
*
* Callable from the main thread, and meant to be: it is the only palette
* read early enough to decide what the first frame is painted in, and
* [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a
* Keystore key. This reads one small JSON file and resolves it.
*/
fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths())
private fun paths(): AndroidPaths {
check(::appContext.isInitialized) { "NativeRuntime is not initialized" } check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
val config = File(appContext.noBackupFilesDir, "config") val config = File(appContext.noBackupFilesDir, "config")
val data = File(appContext.filesDir, "data") val data = File(appContext.filesDir, "data")
val cache = File(appContext.cacheDir, "stream") val cache = File(appContext.cacheDir, "stream")
return AndroidEngine( return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
KeystoreSecretStore(appContext),
)
} }
private fun createEngine(): AndroidEngine =
AndroidEngine(paths(), KeystoreSecretStore(appContext))
} }
@@ -1,343 +0,0 @@
package io.narl.protonstream.playback
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidStream
import uniffi.pstr_android.EpisodeRecord
import kotlin.math.roundToInt
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
class RustStreamSession(
private val episode: EpisodeRecord,
) {
private var engine: AndroidEngine? = null
private var stream: AndroidStream? = null
private var handle: ULong? = null
private var streamSize: ULong? = null
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
val openedEngine = NativeRuntime.engine()
engine = openedEngine
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
streamSize = it.size()
handle = it.nativeHandle()
}
this@RustStreamSession
}
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
fun releaseNative(host: LibmpvHost) {
handle?.let(host::releaseNativeStream)
handle = null
streamSize = null
stream = null
}
/** The native player now owns the published token until stop/end/close. */
fun transferToPlayer() {
handle = null
streamSize = null
stream = null
}
suspend fun closeEngine() = withContext(Dispatchers.IO) {
engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
engine = null
}
}
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
interface LibmpvHost {
fun attachSurface(surface: Surface)
fun detachSurface()
/** Consumes the opaque handle through pstr_android_stream_{read,size}. */
suspend fun play(
nativeHandle: ULong,
size: ULong,
startPosition: Double = 0.0,
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
fun releaseNativeStream(nativeHandle: ULong)
}
private data class PlaybackStartup(
val nativeHandle: ULong,
val size: ULong,
val startPosition: Double,
val audioLanguage: String?,
val subtitleLanguage: String?,
val subtitles: Boolean,
)
@Composable
fun LibmpvPlayerSurface(
titleKey: String,
episode: EpisodeRecord,
host: LibmpvHost? = null,
isFullscreen: Boolean = false,
onToggleFullscreen: () -> Unit = {}
) {
if (host == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
}
return
}
val scope = rememberCoroutineScope()
var playbackError by remember(sessionKey(episode)) { mutableStateOf<String?>(null) }
val session = remember(episode.shareId, episode.linkId) {
RustStreamSession(episode)
}
LaunchedEffect(session, host) {
runCatching {
val startup = withContext(Dispatchers.IO) {
session.open()
val engine = NativeRuntime.engine()
val preferences = engine.titleTrackPreferences(titleKey)
val watch = engine.watchState(episode.shareId, episode.linkId)
PlaybackStartup(
nativeHandle = session.nativeHandle(),
size = session.size(),
startPosition = watch?.positionSecs ?: 0.0,
audioLanguage = preferences.audioLanguage,
subtitleLanguage = preferences.subtitleLanguage,
subtitles = preferences.subtitles,
)
}
host.play(
startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
)
session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" }
}
DisposableEffect(session, host) {
onDispose {
host.detachSurface()
session.releaseNative(host)
scope.launch { session.closeEngine() }
}
}
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls) {
if (showControls) {
delay(4000)
showControls = false
}
}
Box(Modifier.fillMaxSize().clickable(
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
indication = null,
onClick = { showControls = !showControls }
)) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = host.attachSurface(holder.surface)
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
})
}
},
)
AnimatedVisibility(
visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut()
) {
if (host is NativeMpvHost) PlayerControls(
titleKey,
episode,
host,
isFullscreen,
onToggleFullscreen
)
}
playbackError?.let { message ->
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
}
}
}
private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
@Composable
private fun PlayerControls(
titleKey: String,
episode: EpisodeRecord,
host: NativeMpvHost,
isFullscreen: Boolean,
onToggleFullscreen: () -> Unit,
modifier: Modifier = Modifier
) {
val state by host.state.collectAsState()
val tracks by host.tracks.collectAsState()
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
LaunchedEffect(state.position.roundToInt() / 5) {
if (state.duration > 0.0) withContext(Dispatchers.IO) {
NativeRuntime.engine().saveWatchState(
episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
state.ended || state.position >= state.duration * 0.9,
)
}
}
Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
Slider(
value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
onValueChange = { host.seek(it.toDouble()) },
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { host.setPaused(!state.paused) }) {
Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White)
}
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = { host.setMuted(!state.muted) }) {
Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White)
}
IconButton(onClick = onToggleFullscreen) {
Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { choosing = "audio" }) {
Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
}
IconButton(onClick = { choosing = "sub" }) {
Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
}
Text(
"Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " +
"Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}",
color = Color.White,
style = MaterialTheme.typography.bodySmall
)
}
}
choosing?.let { type ->
TrackChooser(
type = type,
tracks = tracks.filter { it.type == type },
onDismiss = { choosing = null },
onSelect = { selected ->
host.selectTrack(selected)
choosing = null
scope.launch(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val old = engine.titleTrackPreferences(titleKey)
engine.setTitleTrackPreferences(
titleKey,
if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
else old.copy(
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
subtitles = selected != null,
),
)
}
},
)
}
}
@Composable
private fun TrackChooser(
type: String,
tracks: List<MpvTrack>,
onDismiss: () -> Unit,
onSelect: (MpvTrack?) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (type == "sub") {
item {
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
}
}
}
items(tracks, key = MpvTrack::id) { track ->
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
Text(if (track.selected) "✓ ${track.label}" else track.label)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
private fun formatTime(seconds: Double): String {
val total = seconds.coerceAtLeast(0.0).roundToInt()
return "%d:%02d".format(total / 60, total % 60)
}
@@ -1,6 +1,7 @@
package io.narl.protonstream.playback package io.narl.protonstream.playback
import android.view.Surface import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.chapterPlan
/**
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
* agree on what "finished" means.
*
* Only [Eof] is an episode watched to the end. Treating the others as one — a
* stop, a shutdown, a failed demux — is what marks an episode that never played
* as watched and autoplays past it.
*/
enum class EndReason {
None,
Eof,
Stopped,
Quit,
Failed,
Other,
;
companion object {
fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
}
}
data class MpvPlaybackState( data class MpvPlaybackState(
val position: Double = 0.0, val position: Double = 0.0,
@@ -24,6 +50,53 @@ data class MpvPlaybackState(
val paused: Boolean = true, val paused: Boolean = true,
val muted: Boolean = false, val muted: Boolean = false,
val ended: Boolean = false, val ended: Boolean = false,
/** Meaningful only while [ended]; [EndReason.None] otherwise. */
val endReason: EndReason = EndReason.None,
/**
* Which episode this reading belongs to, or null before the first poll of a
* newly loaded file. Everything that *writes* per-episode state — resume
* position above all — must check this: one host serves every episode, and
* a reading attributed to the wrong one resumes the next episode where the
* last one stopped.
*/
val media: String? = null,
/** The picture's display size, or zero until mpv has decoded enough to know. */
val videoWidth: Double = 0.0,
val videoHeight: Double = 0.0,
/**
* mpv has run out of buffered data and stopped to refill.
*
* Not the same as [paused], which is the viewer's doing. A frozen picture
* that nothing explains is indistinguishable from a hung app, which is what
* a network-backed stream looked like before this existed.
*/
val buffering: Boolean = false,
/** How full the demuxer cache is while [buffering], 0–100. */
val cachePercent: Double = 0.0,
/** A seek has been issued but playback has not resumed at the new position. */
val seeking: Boolean = false,
) {
/** Whether the picture is stopped for a reason the viewer did not choose. */
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
}
/**
* What is on screen, for the surfaces that are not the player.
*
* The media session, its notification and Picture-in-Picture all have to name
* the episode, and none of them can reach the composition that knows it. The
* player publishes this once per episode and they read it.
*/
data class NowPlaying(
val media: String,
val title: String,
val show: String?,
val detail: String?,
val artworkUrl: String?,
/** The file to pull a Proton thumbnail from when the provider has no poster. */
val artworkFile: ThumbnailSource?,
val hasPrevious: Boolean,
val hasNext: Boolean,
) )
data class MpvTrack( data class MpvTrack(
@@ -38,21 +111,61 @@ data class MpvTrack(
} }
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */ /** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
class NativeMpvHost : LibmpvHost, AutoCloseable { class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
private val lifecycle = ReentrantLock() private val lifecycle = ReentrantLock()
@Volatile private var closed = false @Volatile private var closed = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val mutableState = MutableStateFlow(MpvPlaybackState()) private val mutableState = MutableStateFlow(MpvPlaybackState())
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>()) private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null)
private var poller: Job? = null private var poller: Job? = null
private var currentStreamHandle: Long? = null /** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
var onPlaybackStarted: (() -> Unit)? = null var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null var onExplicitStop: (() -> Unit)? = null
/**
* Where a transport outside the player sends previous/next.
*
* The player owns which episode is open, so the notification, the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives.
*/
var onSkipPrevious: (() -> Unit)? = null
var onSkipNext: (() -> Unit)? = null
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow() val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow() val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
/** The open file's chapters, already resolved into openings and endings. */
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
/** The episode the player has open, for every surface that has to name it. */
val nowPlaying: StateFlow<NowPlaying?> = mutableNowPlaying.asStateFlow()
/**
* True while the player is between episodes.
*
* The end of one episode and the start of the next are separated by a
* network round trip — opening the next revision's stream. The foreground
* service must not retire in that gap: with no foreground state left, the
* `startForeground` on the other side is a background start, which API 31+
* refuses outright.
*/
val advancing: StateFlow<Boolean> = mutableAdvancing.asStateFlow()
/**
* What mpv last complained about, until the viewer dismisses it.
*
* Errors mpv reports are about the *file* — an unreadable header, a codec it
* has no decoder for — and reach nothing else: the play call has already
* returned successfully by the time they happen.
*/
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
/** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
override fun attachSurface(surface: Surface) { override fun attachSurface(surface: Surface) {
@@ -64,6 +177,7 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
} }
override suspend fun play( override suspend fun play(
media: String,
nativeHandle: ULong, nativeHandle: ULong,
size: ULong, size: ULong,
startPosition: Double, startPosition: Double,
@@ -72,69 +186,172 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
subtitles: Boolean, subtitles: Boolean,
) { ) {
check(size > 0uL) { "Cannot play an empty stream" } check(size > 0uL) { "Cannot play an empty stream" }
withContext(Dispatchers.IO) { try {
val loaded = withOpenHandle { withContext(Dispatchers.IO) {
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded -> val loaded = withOpenHandle {
if (loaded) { nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
currentStreamHandle?.let(::pstrAndroidStreamRelease) if (loaded) {
currentStreamHandle = nativeHandle.toLong() // The outgoing stream is released by mpv closing it,
// not from here: `loadfile` is asynchronous and its
// demuxer may still be reading. `stream_close` in
// `pstr_mpv.cpp` owns that release.
//
// The outgoing file's readings describe an episode
// that is no longer open. Retire them here as well
// as natively, so nothing observes them even once.
currentMedia = media
currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
mutableState.value = MpvPlaybackState(
position = startPosition,
volume = mutableState.value.volume,
muted = mutableState.value.muted,
media = media,
)
mutableProblem.value = null
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
}
} }
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
} }
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
} }
} withOpenHandle {
withOpenHandle { onPlaybackStarted?.invoke()
onPlaybackStarted?.invoke() startPolling()
startPolling() }
} finally {
// Whether the next episode arrived or failed to, the gap the
// foreground service was holding open is over.
mutableAdvancing.value = false
} }
} }
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } } /**
* Declare that the player is walking to another episode.
*
* Called before the stream for the next one is opened, and cleared when
* [play] returns either way — the window this covers is the one in which
* nothing is loaded and the service would otherwise conclude playback is
* finished.
*/
fun beginTransition() { mutableAdvancing.value = true }
/** Names the open episode for the media session, its notification and PiP. */
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
fun setPaused(paused: Boolean) {
// The native side writes the flag through, so every transport can flip
// now instead of on the next poll a quarter second from here.
val next = withOpenHandle {
nativePause(it, paused)
mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
} ?: return
onStateChanged?.invoke(next)
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } } fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } } fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } } fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
/** Playback rate, 1.0 being the file's own. Clamped native-side. */
fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
fun selectTrack(track: MpvTrack?) { fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio" val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) } withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
refreshTracks() refreshTracks()
} }
/** Explicit user stop: stop media, release its stream, and retire the foreground service. */ /**
* Explicit user stop: stop media and retire the foreground service.
*
* The stream behind the open file is released by mpv closing it, which
* `stop` causes; releasing it from here would race that close.
*/
fun stop() { fun stop() {
val callback = withOpenHandle { val callback = withOpenHandle {
nativeStop(it) nativeStop(it)
currentStreamHandle?.let(::pstrAndroidStreamRelease) // Nothing loaded means nothing to attribute a reading to: a save
currentStreamHandle = null // issued after this must not land on the episode just stopped.
currentMedia = null
mutableAdvancing.value = false
mutableChapters.value = ChapterPlan(emptyList(), null)
mutableNowPlaying.value = null
onExplicitStop onExplicitStop
} }
callback?.invoke() callback?.invoke()
} }
internal fun releaseEndedStream() {
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
}
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong()) override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
private fun startPolling() { private fun startPolling() {
if (poller?.isActive == true) return if (poller?.isActive == true) return
poller = scope.launch { // Off the main thread deliberately: `nativeState` and `nativeTracks`
// are synchronous `mpv_get_property` calls — five per track for the
// latter — that contend on the core lock the demuxer holds during a
// fetch. A poll that blocks there blocks the UI thread with it.
poller = scope.launch(Dispatchers.Default) {
var tick = 0 var tick = 0
while (isActive) { while (isActive) {
val values = withOpenHandle(::nativeState) ?: break val values = withOpenHandle(::nativeState) ?: break
if (values.size >= 6) { if (values.size >= FIELDS) {
val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0) val next = MpvPlaybackState(
values[0], values[1], values[2],
values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
// Only a reading of the file this host was last asked
// to load carries its episode's name.
media = currentMedia.takeIf { values.generation() == currentGeneration },
videoWidth = values[WIDTH],
videoHeight = values[HEIGHT],
endReason = EndReason.of(values[END_REASON]),
buffering = values[BUFFERING] != 0.0,
cachePercent = values[CACHE_PERCENT],
seeking = values[SEEKING] != 0.0,
)
mutableState.value = next mutableState.value = next
onStateChanged?.invoke(next) // The listeners drive the media session and the player UI,
// both of which are the main thread's.
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
refreshChapters(next)
} }
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks() if (tick++ % 4 == 0) refreshTracks()
delay(250) delay(250)
} }
} }
} }
/**
* Re-read the chapters when there is a reason to: a different file, or a
* duration that has only just arrived. Both change the answer — the length
* of a chapter is what settles whether `Intro` is an opening or ten minutes
* of story — and neither changes again after that, so this settles quickly
* and then costs one comparison per poll.
*/
private fun refreshChapters(state: MpvPlaybackState) {
val generation = currentGeneration
val wanted = state.duration to generation.toRawBits()
if (plannedFor == wanted) return
runCatching {
val encoded = withOpenHandle(::nativeChapters) ?: return
val values = JSONArray(encoded)
List(values.length()) { index ->
values.getJSONObject(index).run {
ChapterRecord(
index = getLong("index"),
title = optString("title").takeIf(String::isNotBlank),
start = getDouble("start"),
)
}
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
}
}
private fun refreshTracks() { private fun refreshTracks() {
runCatching { runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return val encoded = withOpenHandle(::nativeTracks) ?: return
@@ -149,16 +366,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
override fun close() { override fun close() {
scope.cancel() scope.cancel()
lifecycle.withLock { val destroy = lifecycle.withLock {
if (closed) return@withLock if (closed) return@withLock false
closed = true closed = true
onPlaybackStarted = null onPlaybackStarted = null
onStateChanged = null onStateChanged = null
onExplicitStop = null onExplicitStop = null
nativeDestroy(nativeHandle) onSkipPrevious = null
currentStreamHandle?.let(::pstrAndroidStreamRelease) onSkipNext = null
currentStreamHandle = null mutableNowPlaying.value = null
mutableAdvancing.value = false
true
} }
if (!destroy) return
// `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
// parked in a block fetch over a degraded connection. This is called
// from Service.onDestroy — on the main thread — so waiting for it there
// is an ANR. `closed` is already set, so nothing new enters the handle;
// the lock is only to let a call that is already inside finish.
//
// Terminating the core closes the open file, and closing it releases the
// stream — after mpv's last read rather than during it.
Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
} }
/** Serializes destruction against every JNI use of the raw Player pointer. */ /** Serializes destruction against every JNI use of the raw Player pointer. */
@@ -166,7 +395,6 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
if (closed) null else block(nativeHandle) if (closed) null else block(nativeHandle)
} }
private external fun nativeCreate(): Long
private external fun nativeDestroy(handle: Long) private external fun nativeDestroy(handle: Long)
private external fun nativeAttachSurface(handle: Long, surface: Surface) private external fun nativeAttachSurface(handle: Long, surface: Surface)
private external fun nativeDetachSurface(handle: Long) private external fun nativeDetachSurface(handle: Long)
@@ -174,17 +402,47 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
private external fun nativePause(handle: Long, paused: Boolean) private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double) private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double) private external fun nativeVolume(handle: Long, volume: Double)
private external fun nativeSpeed(handle: Long, speed: Double)
private external fun nativeMute(handle: Long, muted: Boolean) private external fun nativeMute(handle: Long, muted: Boolean)
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long) private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
private external fun nativeStop(handle: Long) private external fun nativeStop(handle: Long)
private external fun nativeState(handle: Long): DoubleArray private external fun nativeState(handle: Long): DoubleArray
private external fun nativeTracks(handle: Long): String private external fun nativeTakeError(handle: Long): String?
// Nullable: `NewStringUTF` returns null when the string cannot be built,
// and a non-null declaration turns that into an NPE that `runCatching`
// swallows into a silently empty track list.
private external fun nativeTracks(handle: Long): String?
private external fun nativeChapters(handle: Long): String?
private external fun pstrAndroidStreamRelease(handle: Long) private external fun pstrAndroidStreamRelease(handle: Long)
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
companion object { companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 13
private const val GENERATION = 6
private const val WIDTH = 7
private const val HEIGHT = 8
private const val END_REASON = 9
private const val BUFFERING = 10
private const val CACHE_PERCENT = 11
private const val SEEKING = 12
init { init {
System.loadLibrary("pstr_android") System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv") System.loadLibrary("pstr_mpv")
} }
/**
* A host over a live libmpv core, or null if libmpv would not start.
*
* Null rather than an exception: the service that owns this is created
* by the system, so a throw out of the constructor takes the process
* with it — and the app has a graceful "this build does not include
* libmpv" screen that a crash makes unreachable.
*/
fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
@JvmStatic private external fun nativeCreate(): Long
} }
} }
@@ -5,7 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity import io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.loadArtwork
import io.narl.protonstream.ui.loadThumbnail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */ /** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
class PlaybackService : Service() { class PlaybackService : Service() {
inner class PlaybackBinder : Binder() { inner class PlaybackBinder : Binder() {
val host: NativeMpvHost get() = this@PlaybackService.host val host: NativeMpvHost? get() = this@PlaybackService.host
} }
private lateinit var host: NativeMpvHost /**
* The libmpv core, or null when this build could not start one.
*
* Nullable rather than a `lateinit` that throws: the system creates this
* service, so a failure in its constructor takes the process down and makes
* the app's own "playback is unavailable" screen unreachable.
*/
private var host: NativeMpvHost? = null
private lateinit var mediaSession: MediaSession private lateinit var mediaSession: MediaSession
private lateinit var audioManager: AudioManager private lateinit var audioManager: AudioManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var audioFocus: AudioFocusRequest? = null private var audioFocus: AudioFocusRequest? = null
private var notifiedPaused: Boolean? = null /** Whether the pause on screen is this service's doing, not the viewer's. */
private var pausedForFocus = false
/** The volume before ducking, so the restore does not read a ducked one. */
private var beforeDucking: Double? = null
/**
* Pulling the headphone jack must not continue on the speaker.
*
* Android broadcasts this and expects every media app to handle it; the
* alternative is an episode playing out loud in a quiet room.
*/
private val becomingNoisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
}
}
private var foregroundPlayback = false private var foregroundPlayback = false
private var sessionPlayback = false private var sessionPlayback = false
/**
* What the posted notification already says.
*
* The state poll runs four times a second and almost none of those readings
* change anything the shade shows. Re-posting on every one of them animates
* the media control and costs a binder round trip per tick, so the
* notification is rebuilt only when its own inputs move.
*/
private var notified: NotificationShape? = null
/** The pending teardown, held so an episode transition can call it off. */
private var retire: Job? = null
/** The episode's artwork, and the source key it was fetched for. */
private var artwork: Pair<String, Bitmap>? = null
private val binder = PlaybackBinder() private val binder = PlaybackBinder()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createChannel() createChannel()
ContextCompat.registerReceiver(
this,
becomingNoisy,
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
audioManager = getSystemService(AudioManager::class.java) audioManager = getSystemService(AudioManager::class.java)
host = NativeMpvHost().apply { host = NativeMpvHost.createOrNull()?.apply {
onPlaybackStarted = { onPlaybackStarted = {
requestAudioFocus() // A refusal is rare — a call in progress — but playing over it
// is worse than not playing, and the viewer can simply press
// play again once the call ends.
if (!requestAudioFocus()) setPaused(true)
sessionPlayback = true sessionPlayback = true
if (SettingsStore(this@PlaybackService).backgroundAudio) { cancelRetire()
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
// Convert the bound service into a started foreground // Convert the bound service into a started foreground
// service only when the viewer opted to keep audio alive. // service only when the viewer opted to keep audio alive.
startService(Intent(this@PlaybackService, PlaybackService::class.java)) // Already-foreground is the ordinary case across an episode
startForeground(NOTIFICATION_ID, notification(false)) // transition, and re-entering costs a notification rebuild.
foregroundPlayback = true foregroundPlayback = runCatching {
startService(Intent(this@PlaybackService, PlaybackService::class.java))
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
}.isSuccess
} }
mediaSession.isActive = true mediaSession.isActive = true
} }
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
onExplicitStop = { retirePlayback() } onExplicitStop = { retirePlayback() }
} }
mediaSession = MediaSession(this, "proton-stream").apply { mediaSession = MediaSession(this, "proton-stream").apply {
setSessionActivity(openActivity())
setCallback(object : MediaSession.Callback() { setCallback(object : MediaSession.Callback() {
override fun onPlay() = host.setPaused(false) override fun onPlay() { host?.setPaused(false) }
override fun onPause() = host.setPaused(true) override fun onPause() { host?.setPaused(true) }
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0) override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
override fun onStop() = host.stop() override fun onStop() { host?.stop() }
override fun onSkipToNext() { host?.onSkipNext?.invoke() }
override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
}) })
} }
// Artwork arrives after the episode does, and the shape of the transport
// changes with the playlist position, so the notification follows the
// published episode rather than being built once when playback starts.
val core = host ?: return
scope.launch {
core.nowPlaying.collect { playing ->
// Keyed by whichever source the episode has: with metadata off
// there is no provider poster, and Proton's own thumbnail is
// the only thing the shade can show.
artwork = playing?.let { episode ->
val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
artwork?.takeIf { it.first == key }
?: (
loadArtwork(this@PlaybackService, episode.artworkUrl)
?: loadThumbnail(episode.artworkFile)
)?.let { key to it }
}
refreshSession(core.state.value, playing)
}
}
} }
override fun onBind(intent: Intent?): IBinder = binder override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
ACTION_PLAY -> host.setPaused(false) ACTION_PLAY -> host?.setPaused(false)
ACTION_PAUSE -> host.setPaused(true) ACTION_PAUSE -> host?.setPaused(true)
ACTION_STOP -> host.stop() ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
ACTION_NEXT -> host?.onSkipNext?.invoke()
ACTION_STOP -> host?.stop()
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
scope.cancel()
runCatching { unregisterReceiver(becomingNoisy) }
audioFocus?.let(audioManager::abandonAudioFocusRequest) audioFocus?.let(audioManager::abandonAudioFocusRequest)
mediaSession.release() mediaSession.release()
host.close() host?.close()
super.onDestroy() super.onDestroy()
} }
private fun requestAudioFocus() { /**
* Take audio focus, and honour what the system says about it afterwards.
*
* The four cases are genuinely different and collapsing them is what made
* this feel broken: a phone call is transient and playback should come back
* by itself; a navigation prompt wants the volume down, not a pause; another
* media app taking over for good means giving the focus back rather than
* holding it; and a refusal means not playing at all.
*/
private fun requestAudioFocus(): Boolean {
audioFocus?.let(audioManager::abandonAudioFocusRequest) audioFocus?.let(audioManager::abandonAudioFocusRequest)
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(), .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
) )
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { focus -> .setOnAudioFocusChangeListener { focus ->
when (focus) { when (focus) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true) AudioManager.AUDIOFOCUS_LOSS -> {
AudioManager.AUDIOFOCUS_GAIN -> Unit pausedForFocus = false
audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null
host?.setPaused(true)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Only resume what this paused: a viewer who paused
// before the phone rang expects it still paused after.
pausedForFocus = host?.state?.value?.paused == false
host?.setPaused(true)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
host?.let { core ->
val current = core.state.value.volume
beforeDucking = beforeDucking ?: current
core.setVolume(current * DUCK_FACTOR)
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
beforeDucking?.let { host?.setVolume(it) }
beforeDucking = null
if (pausedForFocus) {
pausedForFocus = false
host?.setPaused(false)
}
}
} }
}.build() }.build()
audioFocus = request audioFocus = request
audioManager.requestAudioFocus(request) return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} }
private fun updateSession(state: MpvPlaybackState) { private fun updateSession(state: MpvPlaybackState) {
refreshSession(state, host?.nowPlaying?.value)
if (state.ended) scheduleRetire() else cancelRetire()
}
/**
* Retire the session once playback is really over.
*
* "This file ended" is not "playback is over": autoplay walks to the next
* episode, and opening its stream is a network round trip. Retiring inside
* that gap leaves the app with no foreground state at all, so the
* `startForeground` on the far side is a background start — which API 31+
* refuses with `ForegroundServiceStartNotAllowedException`, and this app's
* minSdk is 31.
*/
private fun scheduleRetire() {
if (retire?.isActive == true) return
retire = scope.launch {
// The end of file reaches the player and this service from the same
// poll, and the player's advance is a recomposition behind. Give it
// that long to declare a transition before concluding there is none.
delay(TRANSITION_SETTLE_MS)
val host = this@PlaybackService.host ?: return@launch
withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
if (host.state.value.ended) retirePlayback()
}
}
private fun cancelRetire() {
retire?.cancel()
retire = null
}
private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
val status = when { val status = when {
state.ended -> PlaybackState.STATE_STOPPED state.ended -> PlaybackState.STATE_STOPPED
state.paused -> PlaybackState.STATE_PAUSED state.paused -> PlaybackState.STATE_PAUSED
else -> PlaybackState.STATE_PLAYING else -> PlaybackState.STATE_PLAYING
} }
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
mediaSession.setPlaybackState( mediaSession.setPlaybackState(
PlaybackState.Builder() PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP) .setActions(actions)
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f) .setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
.build(), .build(),
) )
mediaSession.setMetadata( mediaSession.setMetadata(metadata(state, playing))
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(), val shape = NotificationShape(
paused = state.paused,
media = playing?.media,
title = playing?.title,
subtitle = playing?.show,
hasPrevious = playing?.hasPrevious == true,
hasNext = playing?.hasNext == true,
hasArtwork = artwork != null,
) )
if (state.ended && foregroundPlayback) { if (foregroundPlayback && notified != shape) {
host.releaseEndedStream() notified = shape
retirePlayback() getSystemService(NotificationManager::class.java)
return .notify(NOTIFICATION_ID, notification(state, playing))
}
if (foregroundPlayback && notifiedPaused != state.paused) {
notifiedPaused = state.paused
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
} }
} }
/**
* What the shade, the lock screen and Android Auto read.
*
* The episode is the title and the show is the artist, which is the mapping
* every media surface already lays out well: one prominent line, one
* secondary, and the poster behind both.
*/
private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
.apply {
playing?.show?.let {
putString(MediaMetadata.METADATA_KEY_ARTIST, it)
putString(MediaMetadata.METADATA_KEY_ALBUM, it)
putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
}
playing?.detail?.let {
putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
}
artwork?.second?.let {
putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
putBitmap(MediaMetadata.METADATA_KEY_ART, it)
putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
}
}
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
.build()
private fun retirePlayback() { private fun retirePlayback() {
cancelRetire()
if (!sessionPlayback && !foregroundPlayback) return if (!sessionPlayback && !foregroundPlayback) return
sessionPlayback = false sessionPlayback = false
notifiedPaused = null notified = null
artwork = null
mediaSession.isActive = false mediaSession.isActive = false
audioFocus?.let(audioManager::abandonAudioFocusRequest) audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null audioFocus = null
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
stopSelf() stopSelf()
} }
private fun notification(paused: Boolean): Notification { private fun openActivity(): PendingIntent = PendingIntent.getActivity(
val open = PendingIntent.getActivity( this,
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, 0,
) Intent(this, MainActivity::class.java),
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
val toggle = PendingIntent.getService( )
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
val stop = PendingIntent.getService( this,
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, requestCode,
) Intent(this, PlaybackService::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
val paused = state.paused
val actions = buildList {
if (playing?.hasPrevious == true) {
add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
}
add(
if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
)
if (playing?.hasNext == true) {
add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
}
add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
}
val style = Notification.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
// The compact view holds three: the transport as it exists for this
// episode, so a first episode does not show a dead Previous.
.setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
return Notification.Builder(this, CHANNEL_ID) return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play) .setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle("proton-stream") .setContentTitle(playing?.title ?: "proton-stream")
.setContentText(if (paused) "Playback paused" else "Playing") .setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
.setContentIntent(open) .apply {
playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
artwork?.second?.let { setLargeIcon(it) }
}
.setColor(ACCENT)
.setColorized(true)
.setContentIntent(openActivity())
.setDeleteIntent(command(2, ACTION_STOP))
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setOngoing(!paused) .setOngoing(!paused)
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build()) .apply { actions.forEach { addAction(it) } }
.addAction(Notification.Action.Builder(null, "Stop", stop).build()) .setStyle(style)
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
.build() .build()
} }
private fun createChannel() { private fun createChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel( getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW), NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
description = "Transport controls for the episode being played."
setShowBadge(false)
},
) )
} }
/** The inputs the posted notification is built from, and nothing else. */
private data class NotificationShape(
val paused: Boolean,
val media: String?,
val title: String?,
val subtitle: String?,
val hasPrevious: Boolean,
val hasNext: Boolean,
val hasArtwork: Boolean,
)
companion object { companion object {
private const val CHANNEL_ID = "playback" private const val CHANNEL_ID = "playback"
private const val NOTIFICATION_ID = 47 private const val NOTIFICATION_ID = 47
/** How long a player gets to declare that it is changing episodes. */
private const val TRANSITION_SETTLE_MS = 1_000L
/** And how long that transition may then take before it is abandoned. */
private const val TRANSITION_GRACE_MS = 30_000L
/** How far the volume drops for a navigation prompt or a notification. */
private const val DUCK_FACTOR = 0.3
/** Catppuccin Mocha mauve, the accent the app is themed with. */
private const val ACCENT = 0xFFCBA6F7.toInt()
private const val ACTION_PLAY = "io.narl.protonstream.PLAY" private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE" private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
private const val ACTION_STOP = "io.narl.protonstream.STOP" private const val ACTION_STOP = "io.narl.protonstream.STOP"
} }
} }
@@ -0,0 +1,984 @@
package io.narl.protonstream.playback
import android.app.Activity
import android.content.pm.ActivityInfo
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Toc
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.AccentTrack
import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.thumbnailSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.skipOffer
import kotlin.math.roundToInt
/** How long the controls stay up after a tap. */
private const val CONTROLS_SECONDS = 4L
/** How long the "up next" card counts down before it loads the next episode. */
private const val UP_NEXT_SECONDS = 10
/** Past this much of an episode it counts as watched. */
private const val WATCHED_FRACTION = 0.9
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
private const val MAX_VOLUME = 100f
/**
* The narrowest layout the in-app volume slider is worth the room it costs.
*
* Below it — a phone held upright — the transport row is already five controls
* wide, and the device's own volume keys are the better answer.
*/
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
/**
* The player, as its own screen on the activity's own window.
*
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
* activity window is what makes "fullscreen" mean the whole panel, notch
* included.
*
* [episodes] is the title's episodes in display order and [index] the one that is
* open, which is what previous/next and autoplay walk. The index is hoisted so
* that it survives process recreation with the rest of the navigation state —
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
*
* In [inPictureInPicture] the window is a thumbnail with the system's own
* controls over it, so this screen draws the picture and nothing else.
*/
@Composable
fun PlayerScreen(
title: TitleRecord,
episodes: List<EpisodeRecord>,
index: Int,
host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean,
onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
onMinimize: () -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val activity = context as? Activity
val titleKey = title.key
val episode = episodes.getOrNull(index)
// Minimised, this screen keeps the stream and the effects that own it, and
// draws nothing: no surface — mpv plays on without one, exactly as it does
// while the app is in the background — no immersive window, and no back
// handler, because back belongs to the page the viewer went to.
if (!minimized) {
ImmersiveWindow(activity)
// Back leaves the video rather than ending it. Stopping is the mini
// transport's ✕, which is the one place it is unambiguous.
BackHandler(onBack = onMinimize)
}
// The transport reads state only this host publishes, so the interface
// alone is not enough to draw a player.
val nativeHost = host as? NativeMpvHost
if (nativeHost == null || episode == null) {
if (minimized) return
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
Text(
if (nativeHost == null) "Playback is unavailable: this build does not include libmpv."
else "This title has no episode to play.",
color = Color.White,
)
}
return
}
val key = mediaKey(episode)
val session = remember(key) { RustStreamSession(episode) }
var playbackError by remember(key) { mutableStateOf<String?>(null) }
LaunchedEffect(session, host) {
runCatching {
val startup = withContext(Dispatchers.IO) {
session.open()
val engine = NativeRuntime.engine()
// A choice made on this title wins; without one, the choice the
// viewer made anywhere else does. Both are the desktop client's
// fallback chain, and it is what makes "Japanese with English
// subtitles" a thing you say once rather than once per show.
val show = engine.titleTrackPreferences(titleKey)
val global = engine.playbackPrefs()
val watch = engine.watchState(episode.shareId, episode.linkId)
Startup(
nativeHandle = session.nativeHandle(),
size = session.size(),
// A finished episode restarts rather than resuming three
// seconds from its own credits.
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
subtitles = show?.subtitles ?: global.subtitles,
volume = global.volume,
muted = global.muted,
speed = global.speed,
)
}
nativeHost.play(
key, startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
)
// After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume)
nativeHost.setMuted(startup.muted)
nativeHost.setSpeed(startup.speed)
session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" }
}
// Per episode: the stream Rust holds open. The surface is the screen's, not
// this episode's, so detaching it belongs to the effect below and not here.
DisposableEffect(session) { onDispose { session.close(nativeHost) } }
DisposableEffect(nativeHost) { onDispose { nativeHost.detachSurface() } }
val state by nativeHost.state.collectAsState()
val chapters by nativeHost.chapters.collectAsState()
val tracks by nativeHost.tracks.collectAsState()
// Read once: a preference the viewer changes mid-episode applies to the
// next one, and re-reading the store per frame would not.
var autoSkip by remember { mutableStateOf(false) }
var autoplay by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { autoSkip = it.autoSkip; autoplay = it.autoplayNext }
}
// Resume position, written only from readings that carry this episode's
// name: one host serves every episode, and an unattributed reading is the
// previous one's clock.
fun save() {
val current = nativeHost.state.value
if (current.media == key && current.duration > 0.0) {
onSaveProgress(
episode,
current.position.coerceIn(0.0, current.duration),
current.duration,
// Watched means played to the end. An episode that was stopped,
// or that mpv could not play at all, also "ended" — recording
// either as watched retires an episode nobody saw.
current.endReason == EndReason.Eof ||
current.position >= current.duration * WATCHED_FRACTION,
)
}
}
LaunchedEffect(key) {
while (true) {
delay(5_000)
save()
}
}
DisposableEffect(key) { onDispose { save() } }
val next = episodes.getOrNull(index + 1)
// Both walks tell the host they are one, so the foreground service holds
// through the gap where nothing is loaded — see NativeMpvHost.advancing.
fun advance() {
save()
next?.let { nativeHost.beginTransition(); onIndexChange(index + 1) }
}
fun retreat() {
if (index == 0) return
save()
nativeHost.beginTransition()
onIndexChange(index - 1)
}
// What the media notification, the lock screen and Picture-in-Picture show.
// None of them can reach this composition, and all three have to name the
// episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish(
NowPlaying(
media = key,
title = episode.label,
show = title.canonicalName ?: title.name,
detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource,
hasPrevious = index > 0,
hasNext = next != null,
),
)
}
// Those same surfaces walk the playlist through here, because this is where
// the open episode lives. Both callbacks run on the main thread.
DisposableEffect(nativeHost, index, episodes.size) {
nativeHost.onSkipNext = { advance() }
nativeHost.onSkipPrevious = { retreat() }
onDispose {
nativeHost.onSkipNext = null
nativeHost.onSkipPrevious = null
}
}
// Autoplay fires on a clean end of file only, and only for this episode's
// own reading — never on a failure, and never on the outgoing file's.
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
}
// A file mpv could not play ends like any other. Saying so is the whole
// difference between a broken episode and an app that silently skipped one.
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Failed && state.media == key) {
playbackError = "This episode could not be played."
}
}
// What mpv itself complained about, which the play call never sees: by the
// time a header turns out to be unreadable it has long since returned.
val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
runCatching { skipOffer(chapters, state.position) }.getOrNull()
}
// Auto-skip is offered once per chapter: seeking back into an opening on
// purpose must not be undone by the setting that skipped it.
val skipped = remember(key) { mutableSetOf<Long>() }
LaunchedEffect(offer?.target) {
val target = offer?.target ?: return@LaunchedEffect
val chapter = chapters.entries.lastOrNull { state.position + 0.001 >= it.start } ?: return@LaunchedEffect
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
}
var landscapeLocked by remember { mutableStateOf(false) }
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls, state.paused) {
if (showControls && !state.paused) {
delay(CONTROLS_SECONDS * 1_000)
showControls = false
}
}
// Everything this screen draws over the picture is sized for a phone panel
// and unreadable in a thumbnail, and Android supplies its own transport
// there. Leaving PiP must find the controls up rather than mid-timeout.
val overlays = !inPictureInPicture
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
if (minimized) return
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = overlays,
onClick = { showControls = !showControls },
),
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
SurfaceView(viewContext).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
host.attachSurface(holder.surface)
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
})
}
},
)
AnimatedVisibility(
visible = overlays && showControls,
modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(),
exit = fadeOut(),
) {
Row(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onMinimize) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Leave the video",
tint = Color.White,
)
}
// The episode leads and the show follows it: which episode is
// the thing a viewer who has just woken the controls is
// checking, and the show is what they already know.
Column(Modifier.weight(1f)) {
Text(
episode.label,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "),
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
}
// Two ways out, because they mean different things: back leaves
// the video and keeps the episode playing, this ends it.
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Stop playback", tint = Color.White)
}
remaining(state)?.let {
Text(
it,
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
)
}
}
}
AnimatedVisibility(
visible = overlays && showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut(),
) {
PlayerControls(
titleKey = titleKey,
host = nativeHost,
state = state,
chapters = chapters.entries,
tracks = tracks,
hasPrevious = index > 0,
hasNext = next != null,
onPrevious = { retreat() },
onNext = { advance() },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
landscapeLocked = landscapeLocked,
)
}
// Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
TonalButton(
onClick = { nativeHost.seek(skip.target) },
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
) { Text(skip.label) }
}
UpNextCard(
next = next,
visible = overlays && autoplay && !state.paused &&
chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() },
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
)
// Only while nothing else explains the still picture: an error is the
// more useful thing to say, and a spinner over it reads as "still
// trying".
if (state.stalled && playbackError == null) {
Column(
Modifier
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
if (state.buffering) {
Text(
state.cachePercent.takeIf { it > 0.0 }
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
playbackError?.let { message ->
Column(
Modifier
.align(Alignment.Center)
.background(MaterialTheme.colorScheme.errorContainer, MaterialTheme.shapes.small)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, color = MaterialTheme.colorScheme.onErrorContainer)
// Dismissible, because the message is not always fatal — a
// subtitle track that failed to load leaves an episode that
// plays perfectly well behind an error that used to be permanent.
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
}
}
}
}
}
private data class Startup(
val nativeHandle: ULong,
val size: ULong,
val startPosition: Double,
val audioLanguage: String?,
val subtitleLanguage: String?,
val subtitles: Boolean,
val volume: Double,
val muted: Boolean,
val speed: Double,
)
/**
* Immersive for as long as this screen is up, and only that long.
*
* Hiding the bars is not enough on its own: without `setDecorFitsSystemWindows`
* and a cutout mode the picture stops at the notch and at the gesture bar, which
* is the "not really fullscreen" everyone notices.
*/
@Composable
private fun ImmersiveWindow(activity: Activity?) {
DisposableEffect(activity) {
val window = activity?.window
if (window == null) return@DisposableEffect onDispose {}
val controller = WindowInsetsControllerCompat(window, window.decorView)
val previousCutout = window.attributes.layoutInDisplayCutoutMode
WindowCompat.setDecorFitsSystemWindows(window, false)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.also {
it.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
controller.show(WindowInsetsCompat.Type.systemBars())
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.also { it.layoutInDisplayCutoutMode = previousCutout }
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
private fun Activity.toggleLandscape(): Boolean {
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
requestedOrientation = if (lock) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
return lock
}
/**
* Everything the transport offers, in two rows.
*
* A phone in landscape has one thumb's worth of room, so the second row is
* icons: the transport cluster on the left, where a thumb already is, and the
* pickers on the right. Only the seek bar gets a full row of its own.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PlayerControls(
titleKey: String,
host: NativeMpvHost,
state: MpvPlaybackState,
chapters: List<ChapterEntry>,
tracks: List<MpvTrack>,
hasPrevious: Boolean,
hasNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
onRotate: () -> Unit,
landscapeLocked: Boolean,
) {
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) }
// What the store says, so the dialog opens on the rate that is playing.
var speed by remember { mutableStateOf(1.0) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { speed = it.speed }
}
val duration = state.duration.toFloat().coerceAtLeast(1f)
val density = LocalDensity.current
val roomForVolume = with(density) {
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
}
Column(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
// Where the thumb is while a finger is on it. A seek per touch-move
// aborts every outstanding prefetch, so a one-second drag used to issue
// dozens of seeks and throw away every in-flight block — and the thumb
// snapped backwards under the finger, because it was drawn from a
// position that only updates four times a second.
var scrubbing by remember { mutableStateOf<Float?>(null) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
formatTime(scrubbing?.toDouble() ?: state.position),
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
Box(Modifier.weight(1f)) {
Slider(
value = scrubbing ?: state.position.toFloat().coerceIn(0f, duration),
onValueChange = { scrubbing = it },
onValueChangeFinished = {
scrubbing?.let { host.seek(it.toDouble()) }
scrubbing = null
},
valueRange = 0f..duration,
track = { AccentTrack(it) },
)
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
}
Text(formatTime(state.duration), color = Color.White, style = MaterialTheme.typography.labelMedium)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CompactIcon(Icons.Default.SkipPrevious, "Previous episode", enabled = hasPrevious, onClick = onPrevious)
CompactIcon(Icons.Default.Replay10, "Back ten seconds") { host.seek(state.position - 10) }
IconButton(onClick = { host.setPaused(!state.paused) }) {
Icon(
if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = if (state.paused) "Play" else "Pause",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
CompactIcon(Icons.Default.Forward30, "Forward thirty seconds") { host.seek(state.position + 30) }
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (chapters.size > 1) {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
}
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
CompactIcon(
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
"Mute",
) {
host.setMuted(!state.muted)
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
}
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next
// one. Only where there is room for it — a phone in portrait
// has the system volume keys and no width to spare.
if (roomForVolume) {
var dragging by remember { mutableStateOf<Float?>(null) }
Slider(
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
onValueChangeFinished = {
val chosen = dragging?.toDouble()
dragging = null
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
},
valueRange = 0f..MAX_VOLUME,
modifier = Modifier.width(96.dp),
)
}
CompactIcon(
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate,
)
}
}
}
if (showSpeeds) {
SpeedChooser(
current = speed,
onDismiss = { showSpeeds = false },
onSelect = { chosen ->
speed = chosen
host.setSpeed(chosen)
showSpeeds = false
scope.launch(Dispatchers.IO) { rememberSpeed(chosen) }
},
)
}
if (showChapters) {
ChapterChooser(
chapters = chapters,
position = state.position,
onDismiss = { showChapters = false },
onSelect = { host.seek(it.start); showChapters = false },
)
}
choosing?.let { type ->
TrackChooser(
type = type,
tracks = tracks.filter { it.type == type },
onDismiss = { choosing = null },
onSelect = { selected ->
host.selectTrack(selected)
choosing = null
scope.launch(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// No per-title choice yet: start from what plays everywhere
// else, so choosing an audio track does not silently drop
// the subtitle preference this episode started with.
val global = engine.playbackPrefs()
val old = engine.titleTrackPreferences(titleKey) ?: TrackPreferencesRecord(
audioLanguage = global.audioLanguage,
subtitleLanguage = global.subtitleLanguage,
subtitles = global.subtitles,
)
engine.setTitleTrackPreferences(
titleKey,
if (type == "audio") {
old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
} else {
// Turning subtitles off keeps the language it was
// off *from*, so turning them back on does not have
// to be told the language again — as on desktop.
old.copy(
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank)
?: old.subtitleLanguage,
subtitles = selected != null,
)
},
)
}
},
)
}
}
/**
* Persist a volume or a mute, leaving whichever was not given as it was.
*
* Written when the thumb is released rather than while it moves: a drag emits
* dozens of values a second and every one of them would be a file rewrite. A
* failure is dropped — the volume is already applied, and the cost of not
* storing it is that the next launch starts where the last one was stored.
*/
private suspend fun rememberVolume(volume: Double? = null, muted: Boolean? = null) {
runCatching {
val engine = NativeRuntime.engine()
val prefs = engine.playbackPrefs()
engine.setPlaybackPrefs(prefs.copy(volume = volume ?: prefs.volume, muted = muted ?: prefs.muted))
}
}
/** Persist a playback rate, so the next episode starts at it. */
private suspend fun rememberSpeed(speed: Double) {
runCatching {
val engine = NativeRuntime.engine()
engine.setPlaybackPrefs(engine.playbackPrefs().copy(speed = speed))
}
}
/**
* The rates worth offering.
*
* A short list rather than a slider: the useful rates are a handful of ratios,
* and a slider on a phone lands on 1.07 as often as on 1.0.
*/
private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
@Composable
private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Double) -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Playback speed") },
text = {
Column(Modifier.fillMaxWidth()) {
SPEEDS.forEach { rate ->
EdgedButton(
onClick = { onSelect(rate) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
(if (kotlin.math.abs(rate - current) < 0.001) "✓ " else "") +
if (rate == 1.0) "Normal" else "${rate}×",
)
}
}
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
/** Where each chapter begins, ticked onto the seek bar. */
@Composable
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
if (chapters.size < 2 || duration <= 0.0) return
Canvas(modifier.padding(horizontal = 10.dp)) {
val width = 2.dp.toPx()
val height = 8.dp.toPx()
chapters.drop(1).forEach { chapter ->
val fraction = (chapter.start / duration).coerceIn(0.0, 1.0).toFloat()
drawRect(
color = Color.White.copy(alpha = 0.75f),
topLeft = Offset(fraction * size.width - width / 2, (size.height - height) / 2),
size = Size(width, height),
)
}
}
}
@Composable
private fun ChapterChooser(
chapters: List<ChapterEntry>,
position: Double,
onDismiss: () -> Unit,
onSelect: (ChapterEntry) -> Unit,
) {
val current = chapters.indexOfLast { position + 0.001 >= it.start }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Chapters") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
items(chapters, key = ChapterEntry::index) { chapter ->
EdgedButton(
onClick = { onSelect(chapter) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
(if (chapters.getOrNull(current) === chapter) "✓ " else "") +
"${formatTime(chapter.start)} ${chapter.label}",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
/**
* The countdown into the next episode, shown once the credits run starts.
*
* "Watch till the end" holds for the rest of the file: someone who wants the
* post-credits scene should have to say so once, not once every ten seconds.
*/
@Composable
private fun UpNextCard(
next: EpisodeRecord?,
visible: Boolean,
onPlayNow: () -> Unit,
modifier: Modifier = Modifier,
) {
var held by remember(next?.linkId) { mutableStateOf(false) }
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
val upNext = next
val showing = visible && upNext != null && !held
LaunchedEffect(showing) {
if (!showing) return@LaunchedEffect
remaining = UP_NEXT_SECONDS
while (remaining > 0) {
delay(1_000)
remaining -= 1
}
onPlayNow()
}
// `showing` already implies a non-null episode, and the compiler knows it.
if (!showing) return
Column(
modifier
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
.padding(12.dp),
) {
Text(
"Up next in ${remaining}s",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
Text(
upNext.label,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
AccentButton(onClick = onPlayNow) { Text("Play now") }
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
}
}
}
@Composable
private fun TrackChooser(
type: String,
tracks: List<MpvTrack>,
onDismiss: () -> Unit,
onSelect: (MpvTrack?) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (type == "sub") {
item {
EdgedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
}
}
}
items(tracks, key = MpvTrack::id) { track ->
EdgedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
Text(if (track.selected) "✓ ${track.label}" else track.label)
}
}
}
},
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
private fun CompactIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
Icon(
icon,
contentDescription = description,
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp),
)
}
}
private fun ((Double) -> Unit).asFloat(): (Float) -> Unit = { this(it.toDouble()) }
/** How much of the episode is left, or nothing before the duration is known. */
private fun remaining(state: MpvPlaybackState): String? = (state.duration - state.position)
.takeIf { state.duration > 0.0 && it >= 1.0 }
?.let { "${formatTime(it)} left" }
private fun formatTime(seconds: Double): String {
val total = seconds.coerceAtLeast(0.0).roundToInt()
val hours = total / 3_600
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
else "%d:%02d".format(total / 60, total % 60)
}
@@ -0,0 +1,102 @@
package io.narl.protonstream.playback
import android.view.Surface
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidStream
import uniffi.pstr_android.EpisodeRecord
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
class RustStreamSession(
private val episode: EpisodeRecord,
) {
private var engine: AndroidEngine? = null
private var stream: AndroidStream? = null
private var handle: ULong? = null
private var streamSize: ULong? = null
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
val openedEngine = NativeRuntime.engine()
engine = openedEngine
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
streamSize = it.size()
handle = it.nativeHandle()
}
this@RustStreamSession
}
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
fun releaseNative(host: LibmpvHost) {
handle?.let(host::releaseNativeStream)
handle = null
streamSize = null
stream = null
}
/** The native player now owns the published token until stop/end/close. */
fun transferToPlayer() {
handle = null
streamSize = null
stream = null
}
/**
* Give the episode's stream back to Rust, from a scope of this session's own.
*
* Deliberately not the caller's: the one moment this has to run is when the
* player leaves the composition, which is the same moment a
* `rememberCoroutineScope` is cancelled — so a release launched there is
* cancelled before it starts, and the stream is held for the life of the
* process. Once per episode played, that is every episode.
*/
fun close(host: LibmpvHost) {
releaseNative(host)
sessions.launch {
runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
engine = null
}
}
}
/**
* Where stream teardown runs: process-lifetime, like the engine it releases to,
* and never cancelled by a screen going away.
*/
private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
interface LibmpvHost {
fun attachSurface(surface: Surface)
fun detachSurface()
/**
* Consumes the opaque handle through pstr_android_stream_{read,size}.
*
* [media] names the episode being loaded. One host serves every episode in
* turn, so its readings have to say which one they describe — see
* [MpvPlaybackState.media].
*/
suspend fun play(
media: String,
nativeHandle: ULong,
size: ULong,
startPosition: Double = 0.0,
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
fun releaseNativeStream(nativeHandle: ULong)
}
/** How an episode is named in watch state, work names and player readings. */
fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
@@ -2,6 +2,13 @@ package io.narl.protonstream.settings
import android.content.Context import android.content.Context
/**
* The settings that are Android's alone.
*
* Playback preferences — volume, languages, autoplay, auto-skip — deliberately
* do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
* there is one definition of them rather than one per front end.
*/
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE) private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
@@ -28,8 +28,11 @@ import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.MatchSummary
import uniffi.pstr_android.MetadataProvider import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
@@ -44,6 +47,7 @@ data class AppUiState(
language = "en", language = "en",
ready = true, ready = true,
), ),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null, val message: String? = null,
) )
@@ -55,6 +59,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val state: StateFlow<AppUiState> = mutableState.asStateFlow() val state: StateFlow<AppUiState> = mutableState.asStateFlow()
init { init {
if (!NativeRuntime.tlsReady) {
mutableState.update {
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
}
}
reload() reload()
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
@@ -83,6 +92,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/**
* Recrawl one share rather than the whole library.
*
* A viewer who has just added files to one link should not have to pay for
* a walk of every other one — and a share whose link has expired makes a
* whole-library refresh fail, which used to leave no way to refresh the
* shares that still work.
*/
fun refreshShare(id: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
/**
* Run enrichment again.
*
* With [force] every title is looked up afresh, including ones that already
* matched — the way out of a library where a provider's early answers were
* wrong, or where a match was made against a provider that has since been
* changed.
*/
fun matchTitles(force: Boolean) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
fun addShare(name: String, url: String, password: String?) { fun addShare(name: String, url: String, password: String?) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -96,6 +142,28 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/**
* Re-supply the link behind a share whose stored secret cannot be read.
*
* The Keystore key is invalidated by a lockscreen change or a device
* restore, and after that every open of every share fails — including the
* read inside "remove this share", so the obvious way out is closed too.
* Re-entering the link rewrites the secret and leaves the catalog and the
* offline files alone.
*/
fun repairShare(id: String, url: String, password: String?) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
engine.crawl(id)
}
}.onFailure(::reportError)
reload()
}
}
fun removeShare(id: String) { fun removeShare(id: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -132,8 +200,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.setMetadataApiKey(provider, apiKey) engine.setMetadataApiKey(provider, apiKey)
} }
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true)) engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
if (enabled) engine.matchTitles(false) if (enabled) engine.matchTitles(false) else null
} }
}.onSuccess { summary ->
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
}.onFailure(::reportError) }.onFailure(::reportError)
reload() reload()
} }
@@ -141,6 +211,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun reloadAfterMetadataChange() = reload() fun reloadAfterMetadataChange() = reload()
/**
* What an enrichment pass did, in one line.
*
* Failures are reported even when most titles matched: a run that quietly
* dropped a third of the library is the case this counting exists for.
*/
private fun describe(summary: MatchSummary): String {
if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
return "Everything is already matched"
}
val parts = mutableListOf("${summary.matched} matched")
if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
if (summary.failed > 0u) parts += "${summary.failed} failed"
return parts.joinToString(", ")
}
fun removeOffline(file: OfflineRecord) { fun removeOffline(file: OfflineRecord) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
@@ -170,16 +257,96 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/**
* Persist where an episode was left.
*
* The player calls this while it is still up and once more as it leaves, so
* it runs on the ViewModel's scope rather than the screen's — a save issued
* on the way out must not be cancelled by the screen going away.
*/
fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().saveWatchState(
episode.shareId,
episode.linkId,
position.coerceIn(0.0, duration),
duration,
watched,
)
}
}.onFailure(::reportError)
}
}
/**
* Mark an episode seen, or unseen, without playing it.
*
* Unwatching rewinds, matching the desktop client: an episode marked unseen
* that kept its position would come straight back as "resume at 19:04",
* which is not what the viewer asked for. Marking one seen puts the position
* at its duration where one is known, so the progress bar agrees with the
* tick.
*/
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
engine.saveWatchState(
episode.shareId,
episode.linkId,
if (watched) duration ?: 0.0 else 0.0,
duration,
watched,
)
}
}.onFailure(::reportError)
reload()
}
}
fun removeAllOffline() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
NativeRuntime.engine().removeAllOffline()
DownloadStateStore(appContext).clear()
}
}.onFailure(::reportError)
reload()
}
}
fun clearBlockCache() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
}.onSuccess { reclaimed ->
mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
}.onFailure(::reportError)
reload()
}
}
private fun reload() { private fun reload() {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine() val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
Reloaded( Reloaded(
engine.shares(), engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)), engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.offlineFiles(), engine.offlineFiles(),
engine.metadataSettings(), engine.metadataSettings(),
engine.storageUsage(),
) )
} }
}.onSuccess { result -> }.onSuccess { result ->
@@ -189,6 +356,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
titles = result.titles, titles = result.titles,
offline = result.offline, offline = result.offline,
metadataSettings = result.metadataSettings, metadataSettings = result.metadataSettings,
storage = result.storage,
) } ) }
} }
.onFailure { error -> .onFailure { error ->
@@ -215,6 +383,7 @@ private data class Reloaded(
val titles: List<TitleRecord>, val titles: List<TitleRecord>,
val offline: List<OfflineRecord>, val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord, val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord,
) )
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow { private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,9 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -19,50 +22,61 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URI import java.net.URI
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Collections
/** HTTPS-only artwork loader backed by the app-private cache directory. */ /**
* One file to pull Proton's own thumbnail from when no provider poster exists.
*
* Proton renders a thumbnail per file, so any episode of a show is a frame of
* the right show — the desktop client picks the first for the same reason.
*/
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
val key: String get() = "$shareId/$linkId"
}
/**
* What the first episode of a title offers as a poster.
*
* Metadata lookups are off by default, so for a freshly crawled library this is
* the only artwork there is.
*/
internal val TitleRecord.thumbnailSource: ThumbnailSource?
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
/** What one episode offers as a still. */
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
get() = ThumbnailSource(shareId, volumeId, linkId)
/**
* HTTPS-only artwork loader backed by the app-private cache directory.
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
*/
@Composable @Composable
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) { internal fun RemoteArtwork(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
) {
val context = LocalContext.current val context = LocalContext.current
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) } var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) { LaunchedEffect(url, fallback) {
image = url?.let { requested -> image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val bytes = if (cached.isFile) cached.readBytes() else {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
connection.inputStream.use(::readBounded).also { downloaded ->
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(downloaded)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
}
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
}
}
} }
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) { Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let { image?.let {
@@ -71,7 +85,153 @@ internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier
} }
} }
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
* it from a service with no composition of its own — but it wants exactly this
* loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download.
*/
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val fresh = !cached.isFile
val bytes = if (fresh) download(uri) else cached.readBytes()
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
if (fresh) {
// Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch
// that survives until app data is cleared.
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(bytes)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
prune(directory)
} else {
cached.setLastModified(System.currentTimeMillis())
}
bitmap
}.getOrNull()?.also { decoded.put(requested, it) }
}
}
/**
* Proton's own thumbnail for one file, decoded.
*
* The bridge holds the disk cache, so what is worth keeping here is the decoded
* bitmap and the *absence* of one: a file with no thumbnail is common, and
* without remembering that, every scroll back over the grid pays a round trip
* per tile to be told so again. A failed call is not remembered — an unreachable
* share or a session that has to be reopened is transient, and the next
* composition should try again.
*/
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
val file = source ?: return null
val key = file.key
decoded.get(key)?.let { return it }
if (key in missing) return null
val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
}
val bytes = fetched.getOrElse { return null }
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
if (bitmap == null) {
missing.add(key)
return null
}
decoded.put(key, bitmap)
return bitmap
}
private fun download(uri: URI): ByteArray {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
return connection.inputStream.use(::readBounded)
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
}
/**
* Decode within a bound the display can actually use.
*
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
* costs no allocation and settles a sample size before anything is committed.
*/
private fun decodeSampled(bytes: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null
var sample = 1
while (longest / sample > MAX_ART_EDGE) sample *= 2
return BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply { inSampleSize = sample },
)
}
/**
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
*
* `cacheDir` is reclaimable by the system, but only under storage pressure and
* only wholesale — a poster cache that grows with every title ever browsed is
* still the app's own doing.
*/
private fun prune(directory: File) {
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
var total = files.sumOf(File::length)
for (file in files) {
if (total <= MAX_ART_CACHE_BYTES) return
total -= file.length()
file.delete()
}
}
/**
* Files known to have no Proton thumbnail, so the answer is paid for once.
*
* Deliberately not persisted: a re-upload can add one, and the cost of being
* wrong for a session is a placeholder that a restart clears.
*/
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
/**
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
*
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
* cannot collide, since one is always an `https:` URL.
*
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
* recreates items as they leave the viewport — without this, every scroll back
* is a full-resolution decode per item.
*/
private val decoded = object : LruCache<String, Bitmap>(
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
private const val MAX_ART_BYTES = 12 * 1024 * 1024 private const val MAX_ART_BYTES = 12 * 1024 * 1024
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
private const val MAX_ART_EDGE = 1_024
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */ /** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
private fun readBounded(input: InputStream): ByteArray { private fun readBounded(input: InputStream): ByteArray {
@@ -0,0 +1,223 @@
package io.narl.protonstream.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.SliderState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
/**
* The accent, as a gradient when there is one to draw.
*
* The desktop paints the accent as a two-hue ramp on everything that is a
* control — the seek bar, the primary buttons, the tile progress bar — and the
* viewer's "paint the accent as a gradient" toggle is what turns it off. That
* toggle was already stored, already offered on this screen, and drawn nowhere
* on Android, so the same stored setting meant two different things depending
* on which client read it.
*
* Nothing here reads the toggle. It does not need to: when gradients are off,
* `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses
* to a flat fill on its own. `primary` and `secondary` are those two colours —
* see [schemeOf] — which is why this asks Material for them rather than
* reaching past it for the palette.
*/
@Composable
internal fun accentBrush(): Brush {
val scheme = MaterialTheme.colorScheme
return remember(scheme.primary, scheme.secondary) {
Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary))
}
}
/**
* The primary button: the accent ramp, at the shared corner radius.
*
* Material's filled button has its own shape token (`CornerFull`) that does not
* read `MaterialTheme.shapes`, so the radius has to be handed to it — which is
* the whole reason the plain buttons below exist as well. They add nothing but
* that.
*/
@Composable
internal fun AccentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
val shape = MaterialTheme.shapes.small
Button(
onClick = onClick,
// Only while it is live: a disabled button keeps Material's flat
// disabled container, because a greyed control that still wears the
// one strong colour in the window reads as pressable.
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
enabled = enabled,
shape = shape,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
content = content,
)
}
@Composable
internal fun TonalButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
@Composable
internal fun EdgedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
@Composable
internal fun QuietButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
/**
* How far along something is, drawn in the accent.
*
* Hand-drawn rather than `LinearProgressIndicator` because that one takes a
* `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop
* indicator either side of the bar — detail at a size where the desktop draws a
* plain bar. [fill] is a parameter for the one bar that is deliberately not the
* accent: a download in flight sits directly under the watch-progress bar of
* the same row, and two ramps in the same colour stacked two pixels apart say
* nothing.
*/
@Composable
internal fun AccentProgress(
progress: () -> Float,
modifier: Modifier = Modifier,
fill: Brush? = null,
) {
val brush = fill ?: accentBrush()
val track = MaterialTheme.colorScheme.surfaceContainerHighest
Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = track, cornerRadius = radius)
val done = size.width * progress().coerceIn(0f, 1f)
// Below one bar's width there is nothing to round, and a rounded rect
// narrower than its own corners draws as a wrong-looking pill.
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
}
}
}
/**
* The seek bar's track, in the accent ramp.
*
* Passed to `Slider`'s `track` slot, so the thumb, the gestures and the
* accessibility semantics stay Material's — this replaces the two rectangles it
* would have drawn and nothing else.
*
* The spent side is the accent and the rest is white at a third: the seek bar
* is the one accent surface that sits over arbitrary film footage, where a
* palette colour for the *unplayed* remainder would be a coloured line over a
* picture rather than the absence of one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
val brush = accentBrush()
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
val done = size.width * state.coercedValueAsFraction
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
}
}
}
/**
* The navigation pill, in the accent.
*
* Solid rather than a ramp, and this is the one place the two clients cannot
* agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`,
* with no slot to draw into, so a gradient here would mean reimplementing the
* bar. The accent itself is still the right answer — the desktop's rule is that
* the accent marks the thing you are on — and it is nearer to that than
* `secondaryContainer`, which is the accent taken most of the way back to the
* page.
*/
@Composable
internal fun accentNavigationColors(): NavigationSuiteItemColors {
val scheme = MaterialTheme.colorScheme
return NavigationSuiteDefaults.itemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onSurface,
indicatorColor = scheme.primary,
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onSurface,
indicatorColor = scheme.primary,
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = scheme.primary,
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onPrimary,
),
)
}
/** A solid fill, for the callers of [AccentProgress] that want one colour. */
internal fun solid(color: Color): Brush = SolidColor(color)
/** Thin enough to sit under a row without becoming part of it. */
private const val PROGRESS_HEIGHT = 4f
/** The seek bar is dragged with a finger, so it is the thicker of the two. */
private const val TRACK_HEIGHT = 6f
@@ -0,0 +1,36 @@
package io.narl.protonstream.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* One corner radius, which is the desktop client's corner radius.
*
* `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab
* pill, the progress bars — and only a badge over artwork is tighter, at 4.
* Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen
* puts a 28dp bottom sheet next to a 12dp card next to a fully-round button,
* and none of those is the shape the other client draws.
*
* Flat rather than a ramp, therefore, with the two ends kept for the two cases
* where a single radius is wrong: `extraSmall` for something small enough that
* 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of
* the screen and need to read as a lifted sheet rather than a seam.
*/
internal val ProtonShapes = Shapes(
extraSmall = RoundedCornerShape(CORNER_TIGHT.dp),
small = RoundedCornerShape(CORNER.dp),
medium = RoundedCornerShape(CORNER.dp),
large = RoundedCornerShape(CORNER.dp),
extraLarge = RoundedCornerShape(CORNER_SHEET.dp),
)
/** The radius `pstr-app` draws everything at. */
internal const val CORNER = 8f
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
internal const val CORNER_TIGHT = 4f
/** A bottom sheet, which has no desktop counterpart. */
internal const val CORNER_SHEET = 16f
@@ -1,24 +1,216 @@
package io.narl.protonstream.ui.theme package io.narl.protonstream.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import uniffi.pstr_android.PaletteRecord
private val ProtonColors = darkColorScheme( /**
primary = Color(0xFFCBA6F7), * The palette in force, shared across every composition.
onPrimary = Color(0xFF1E1E2E), *
secondary = Color(0xFFF5C2E7), * The choice is stored by Rust and resolved by Rust — the same flavours, the
background = Color(0xFF1E1E2E), * same accent pairings and the same contrast rule the desktop client uses — so
surface = Color(0xFF181825), * what lives here is only the resolved answer and a way to replace it when the
surfaceVariant = Color(0xFF313244), * viewer picks something else.
onBackground = Color(0xFFCDD6F4), */
onSurface = Color(0xFFCDD6F4), object AppearanceState {
outline = Color(0xFF6C7086), private val current = MutableStateFlow<PaletteRecord?>(null)
error = Color(0xFFF38BA8), val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
)
/**
* The palette read before the first frame, from
* [io.narl.protonstream.native.NativeRuntime.storedPalette].
*
* Separate from [load] because it runs on the main thread during
* `onCreate`: it is the only read that is early enough to decide what the
* first composition is painted in, and it is cheap enough to be allowed
* there.
*/
fun seed(palette: PaletteRecord) {
current.value = palette
}
/**
* Read the stored choice through the engine. Safe to call repeatedly; the
* last read wins.
*
* A no-op once [seed] has run — it resolves the same file through the same
* Rust, so the only thing a second read can do is build the engine earlier
* than the first screen needed it.
*/
suspend fun load() {
if (current.value != null) return
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) {
current.value = palette
}
}
@Composable @Composable
fun ProtonStreamTheme(content: @Composable () -> Unit) { fun ProtonStreamTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = ProtonColors, content = content) val palette by AppearanceState.palette.collectAsState()
// Before the first read, and if it fails, the palette this app shipped
// with — a theme file that cannot be read must never be a reason the
// library does not open.
LaunchedEffect(Unit) { AppearanceState.load() }
MaterialTheme(
colorScheme = schemeOf(palette ?: ProtonFallback),
// The other two thirds of what the clients share. Colour was already
// one resolved answer for both; type and shape were Material's defaults
// here and named constants there, which is most of why the two read as
// different apps even where the colours agreed.
typography = ProtonTypography,
shapes = ProtonShapes,
content = content,
)
} }
/**
* One resolved palette as Material's roles — **all** of them.
*
* Every role Material names has to be answered here, because a role left out
* does not fall back to something neutral: it keeps the value
* `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own
* baseline purple-and-grey and belongs to no flavour this app ships.
*
* That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with
* (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in
* the library grid, every episode row, every share and every download was drawn
* in baseline neutral on top of a flavoured page. `inverseSurface` is the
* snackbar — the app's only error channel — and `scrim` is the dim behind every
* dialog.
*
* Material names more roles than a palette has colours, so several are answered
* by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the
* blend behind them is the shared one. `SchemeRolesTest` asserts that nothing
* here is left at its baseline.
*/
internal fun schemeOf(palette: PaletteRecord): ColorScheme {
val base = if (palette.light) lightColorScheme() else darkColorScheme()
return base.copy(
primary = color(palette.accent),
onPrimary = color(palette.onAccent),
primaryContainer = color(palette.accentDim),
onPrimaryContainer = color(palette.text),
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
// is the accent taken towards the page, so it inverts with the rest.
inversePrimary = color(palette.accentDim),
secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent),
// The accent taken back towards the page, not the card's hover shade.
// `secondaryContainer` is what fills every tonal button and the
// navigation bar's selected pill, and `cardHover` sits one step off
// `card` — which on a dark flavour made all of them dark grey shapes on
// a dark grey row, readable as text but not as controls.
secondaryContainer = color(palette.accentDim),
onSecondaryContainer = color(palette.text),
// The palette has two hues, not three. Tertiary takes the second one —
// the gradient partner — rather than inventing a third that no flavour
// chose.
tertiary = color(palette.accentAlt),
onTertiary = color(palette.onAccent),
tertiaryContainer = color(palette.accentDim),
onTertiaryContainer = color(palette.text),
background = color(palette.background),
onBackground = color(palette.text),
surface = color(palette.surface),
onSurface = color(palette.text),
surfaceVariant = color(palette.card),
onSurfaceVariant = color(palette.muted),
// No elevation tint. Material tints a raised surface with `primary`,
// and the desktop draws no such tint at all — its rule is that the
// accent is the only strong colour in the window and everything wearing
// it is something to click. A card that is faintly accent-coloured
// because it happens to be raised breaks that.
surfaceTint = Color.Transparent,
// Genuinely inverted, both ways round: on a dark flavour this is a pale
// snackbar with dark ink, on Latte a dark one with pale ink.
inverseSurface = color(palette.text),
inverseOnSurface = color(palette.background),
error = color(palette.danger),
onError = color(palette.background),
errorContainer = color(palette.dangerDim),
onErrorContainer = color(palette.text),
// Swapped from what was here before, to match what Material draws with
// each: `outline` is a border meant to be seen (an outlined button,
// a swatch that is not selected), `outlineVariant` is a divider meant
// to be barely there. `border` is the fainter of the two.
outline = color(palette.muted),
outlineVariant = color(palette.border),
// Black in both of Material's own schemes, and for the same reason: a
// scrim is not a colour, it is an absence of light.
scrim = Color.Black,
surfaceBright = color(palette.elevated),
surfaceDim = color(palette.background),
// The containment ladder, in the order the palette already runs: the
// deepest colour in the flavour at the bottom, `elevated` — one step
// past the card's hover shade — at the top.
surfaceContainerLowest = color(palette.sunken),
surfaceContainerLow = color(palette.surface),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerHighest = color(palette.elevated),
// The fixed roles are Material's "the same in light and dark", which a
// per-flavour palette has no equivalent of. They take the accent pair
// rather than a baseline hue, so a component that reaches for one still
// lands inside the flavour.
primaryFixed = color(palette.accentDim),
primaryFixedDim = color(palette.accent),
onPrimaryFixed = color(palette.text),
onPrimaryFixedVariant = color(palette.muted),
secondaryFixed = color(palette.accentDim),
secondaryFixedDim = color(palette.accentAlt),
onSecondaryFixed = color(palette.text),
onSecondaryFixedVariant = color(palette.muted),
tertiaryFixed = color(palette.accentDim),
tertiaryFixedDim = color(palette.accentAlt),
onTertiaryFixed = color(palette.text),
onTertiaryFixedVariant = color(palette.muted),
)
}
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
private fun color(argb: UInt) = Color(argb.toInt())
/**
* The shipped default, spelled out for the case where Rust cannot be asked.
*
* A `PaletteRecord` rather than a `ColorScheme`, so that it goes through
* [schemeOf] like any other palette and inherits every mapping above — a
* fallback that answered only ten roles would be the same bug in a smaller
* place. The values are `pstr_core::appearance::Palette::PROTON`.
*/
internal val ProtonFallback = PaletteRecord(
background = 0xFF0E0E12u,
surface = 0xFF17171Du,
sunken = 0xFF0A0A0Du,
card = 0xFF1E1E26u,
cardHover = 0xFF2A2A35u,
border = 0xFF262630u,
text = 0xFFEAEAF0u,
muted = 0xFF8E8E9Cu,
accent = 0xFF7D4DFFu,
accentAlt = 0xFF3F6FE0u,
accentDim = 0xFF5333ADu,
onAccent = 0xFFEAEAF0u,
danger = 0xFFE05561u,
elevated = 0xFF333340u,
dangerDim = 0xFF7F2F37u,
light = false,
)
@@ -0,0 +1,75 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* The type ramp, which is the desktop client's type ramp.
*
* `pstr_app::theme::Role` names nine sizes and the desktop asks for a role
* rather than a number at every call site. Material names fifteen styles, so
* several of them land on the same rung — that is the point. The two clients
* were built to share one palette; sharing one scale is the other half of why
* they should read as one app.
*
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
*
* Where a role maps is decided by what actually draws with it here, not by
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
* a phone's lock screen and not for anything in a library grid. `bodyLarge` is
* what an unstyled `Text` gets, so it takes the desktop's body rung and the two
* smaller body styles step down from there.
*
* [TypeRampTest] pins every one of these against the Rust ramp.
*/
internal val ProtonTypography = Typography(
displayLarge = role(DISPLAY, FontWeight.SemiBold),
displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line.
headlineLarge = role(DISPLAY, FontWeight.SemiBold),
headlineMedium = role(DISPLAY, FontWeight.SemiBold),
// A dialog's heading — `AlertDialog` titles draw with this one.
headlineSmall = role(HEADING, FontWeight.SemiBold),
// A page heading: "Offline downloads", "Settings".
titleLarge = role(TITLE, FontWeight.SemiBold),
// A run of content under a heading: "Continue watching", "Playback".
titleMedium = role(SECTION, FontWeight.Medium),
// A row that groups the rows under it — a season header.
titleSmall = role(SUBHEAD, FontWeight.Medium),
bodyLarge = role(BODY, FontWeight.Normal),
bodyMedium = role(LABEL, FontWeight.Normal),
// Context rather than content: counts, sizes, the note under a field.
bodySmall = role(CAPTION, FontWeight.Normal),
// The text on a button.
labelLarge = role(LABEL, FontWeight.Medium),
labelMedium = role(CAPTION, FontWeight.Medium),
// The smallest thing that stays legible.
labelSmall = role(MICRO, FontWeight.Medium),
)
internal const val DISPLAY = 26f
internal const val TITLE = 20f
internal const val HEADING = 18f
internal const val SECTION = 17f
internal const val SUBHEAD = 15f
internal const val BODY = 14f
internal const val LABEL = 13f
internal const val CAPTION = 12f
internal const val MICRO = 11f
/**
* One rung as a `TextStyle`.
*
* Line height is derived rather than named per style: a ramp whose leading was
* chosen nine separate times is the same problem the sizes had. 1.35 is loose
* enough for the two-line title names in the grid and tight enough that a
* label on a control does not float.
*/
private fun role(size: Float, weight: FontWeight) = TextStyle(
fontSize = size.sp,
lineHeight = (size * 1.35f).sp,
fontWeight = weight,
)
@@ -0,0 +1,13 @@
<!--
The status-bar glyph. Android tints small icons to a single colour and drops
everything that is not opaque, so this is the launcher mark reduced to one
white silhouette rather than the two-tone drawable.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
</vector>
@@ -0,0 +1,9 @@
<resources>
<!--
`pstr_core::appearance::Palette::PROTON.background` — the shipped default,
and the only colour in this file. It is what the window is painted before
the stored palette has been read; from then on
MainActivity.dressWindow overrides it with the flavour in force.
-->
<color name="window_background">#0E0E12</color>
</resources>
+16 -1
View File
@@ -1,8 +1,23 @@
<resources> <resources>
<!--
The window before Compose owns it.
Everything about the *look* is decided by the stored palette and applied
in MainActivity.dressWindow: the background colour here is only the one
the shipped default resolves to, so that a first-ever launch — before
there is a theme file to read — starts on the right colour rather than on
the platform's white. The bar polarity is likewise set from the palette;
what is left here is the dark reading, which is right for four of the five
flavours and is corrected in the same frame for the fifth.
A hex literal from one specific flavour used to live here, and it was the
reason every cold start flashed Catppuccin Mocha's base regardless of what
the viewer had chosen.
-->
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar"> <style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item> <item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item> <item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">#1E1E2E</item> <item name="android:windowBackground">@color/window_background</item>
</style> </style>
</resources> </resources>
@@ -0,0 +1,155 @@
package io.narl.protonstream.download
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DownloadStateStoreTest {
private lateinit var context: Context
private lateinit var store: DownloadStateStore
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
store = DownloadStateStore(context)
}
private fun record(
shareId: String = "share-1",
linkId: String = "link-1",
label: String = "S01E01.mkv",
status: String = RetainedDownload.STATUS_RUNNING,
error: String? = null,
) = RetainedDownload(
shareId = shareId,
volumeId = "volume-1",
linkId = linkId,
label = label,
downloaded = 4L * 1024 * 1024,
total = 761L * 1024 * 1024,
status = status,
error = error,
)
@Test
fun `a record survives a round trip unchanged`() {
val original = record()
store.put(original)
assertEquals(original, store.get("share-1", "link-1"))
}
@Test
fun `an absent record reads as null rather than a default`() {
assertNull(store.get("share-1", "link-1"))
}
@Test
fun `an error message round trips and a null one stays null`() {
store.put(record(error = "no space left on device"))
assertEquals("no space left on device", store.get("share-1", "link-1")?.error)
store.put(record(error = null))
assertNull(store.get("share-1", "link-1")?.error)
}
/**
* `encode` writes `error` through `JSONObject.put`, which *removes* the key
* when the value is null. `decode` then reads `optString`, which answers ""
* rather than null. The blank check is the only thing standing between a
* cleared error and a download that renders as permanently failed.
*/
@Test
fun `a blank error is not read as a failure message`() {
writeRaw(
"share-1",
"link-1",
baseJson().put("error", ""),
)
assertNull(store.get("share-1", "link-1")?.error)
}
/** Rows written before `label` existed fall back to the link id. */
@Test
fun `a record with no label is captioned with its link id`() {
writeRaw("share-1", "link-1", baseJson().apply { remove("label") })
assertEquals("link-1", store.get("share-1", "link-1")?.label)
}
@Test
fun `a corrupt row is skipped rather than failing the whole list`() {
store.put(record(linkId = "link-1", label = "good.mkv"))
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString("share-1\u001flink-broken", "{ not json")
.commit()
val records = store.records()
assertEquals(1, records.size)
assertEquals("good.mkv", records.first().label)
}
@Test
fun `records are ordered by label without regard to case`() {
store.put(record(linkId = "a", label = "beta.mkv"))
store.put(record(linkId = "b", label = "Alpha.mkv"))
assertEquals(listOf("Alpha.mkv", "beta.mkv"), store.records().map { it.label })
}
@Test
fun `removing one share leaves the others alone`() {
store.put(record(shareId = "share-1", linkId = "a"))
store.put(record(shareId = "share-2", linkId = "b"))
store.removeShare("share-1")
assertEquals(listOf("share-2"), store.records().map { it.shareId })
}
@Test
fun `clear forgets everything`() {
store.put(record(linkId = "a"))
store.put(record(linkId = "b"))
store.clear()
assertTrue(store.records().isEmpty())
}
/**
* The key is what `DownloadCoordinator` and `OfflineDownloadWorker` agree on
* across processes; two shares may legitimately hold the same link id.
*/
@Test
fun `the key separates share from link so ids cannot run together`() {
store.put(record(shareId = "a", linkId = "b-c"))
store.put(record(shareId = "a-b", linkId = "c"))
assertEquals(2, store.records().size)
}
private fun baseJson() = JSONObject()
.put("share", "share-1")
.put("volume", "volume-1")
.put("link", "link-1")
.put("label", "S01E01.mkv")
.put("downloaded", 0)
.put("total", 0)
.put("status", RetainedDownload.STATUS_QUEUED)
private fun writeRaw(shareId: String, linkId: String, json: JSONObject) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putString("$shareId\u001f$linkId", json.toString())
.commit()
}
private companion object {
const val PREFERENCES = "offline_download_state"
}
}
@@ -0,0 +1,129 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import uniffi.pstr_android.PaletteRecord
/**
* That every Material colour role is answered by the palette.
*
* Material names roughly fifty roles and a `ColorScheme` is built by copying a
* baseline, so a role nobody names keeps Material's own purple-and-grey —
* silently, and only where that particular role happens to be drawn. That is
* how `surfaceContainerHighest` went unnoticed: nothing referred to it by name,
* but it is what `Card` fills with, so every tile, episode row, share and
* download in the app was baseline neutral on a flavoured page.
*
* The check is reflective rather than a list, because a list is exactly as
* incomplete as the mapping it was written from — and because Material adds
* roles between versions, and a role added tomorrow should fail this the day
* the dependency moves.
*/
class SchemeRolesTest {
/**
* A palette whose every colour is distinct and none of which Material would
* ever produce, so that "this role came from the palette" is decidable by
* looking at the value.
*/
private fun sentinels(light: Boolean) = PaletteRecord(
background = 0xFF010203u,
surface = 0xFF040506u,
sunken = 0xFF070809u,
card = 0xFF0A0B0Cu,
cardHover = 0xFF0D0E0Fu,
border = 0xFF101112u,
text = 0xFF131415u,
muted = 0xFF161718u,
accent = 0xFF191A1Bu,
accentAlt = 0xFF1C1D1Eu,
accentDim = 0xFF1F2021u,
onAccent = 0xFF222324u,
danger = 0xFF252627u,
elevated = 0xFF28292Au,
dangerDim = 0xFF2B2C2Du,
light = light,
)
/** Every role on a `ColorScheme`, read by name, whatever the version names. */
private fun roles(scheme: ColorScheme): Map<String, Color> =
ColorScheme::class.java.declaredMethods
.filter { it.name.startsWith("get") && it.parameterCount == 0 && it.returnType == Long::class.java }
.associate { method ->
method.isAccessible = true
// Kotlin mangles the getter of a value-class property:
// `getPrimary-0d7_KjU`. The property is what the name says up
// to the dash.
method.name.removePrefix("get").substringBefore('-') to
Color((method.invoke(scheme) as Long).toULong())
}
@Test
fun `every role is answered by the palette`() {
for (light in listOf(false, true)) {
val palette = sentinels(light)
val allowed = buildSet {
addAll(
listOf(
palette.background, palette.surface, palette.sunken, palette.card,
palette.cardHover, palette.border, palette.text, palette.muted,
palette.accent, palette.accentAlt, palette.accentDim, palette.onAccent,
palette.danger, palette.elevated, palette.dangerDim,
).map { Color(it.toInt()) },
)
// The two roles that are deliberately not a palette colour: no
// elevation tint at all, and a scrim that is an absence of
// light rather than a hue.
add(Color.Transparent)
add(Color.Black)
}
val leaked = roles(schemeOf(palette)).filterValues { it !in allowed }
assertTrue(
"light=$light: ${leaked.keys.sorted()} still carry Material's baseline",
leaked.isEmpty(),
)
}
}
/**
* The count is asserted too. A reflective sweep that found no roles at all
* would pass the check above for the wrong reason.
*/
@Test
fun `the sweep sees the whole scheme`() {
val count = roles(darkColorScheme()).size
assertTrue("only $count roles found; the reflective sweep is not working", count >= 40)
assertEquals(count, roles(lightColorScheme()).size)
}
/**
* Which baseline was copied stops being observable once every role is
* mapped — and that is the point.
*
* `light` still decides plenty, but it decides it in Rust, where it picks
* the flavour's colours. By the time a palette reaches Kotlin the
* light-or-dark question is already answered *inside* the colours, so two
* palettes that differ only in the flag have to produce the same scheme. If
* they ever stop doing so, something is reading `light` here rather than
* reading the colours.
*/
@Test
fun `the baseline no longer shows through`() {
assertEquals(roles(schemeOf(sentinels(false))), roles(schemeOf(sentinels(true))))
assertEquals(Color.Transparent, schemeOf(sentinels(false)).surfaceTint)
}
/** The fallback goes through the same mapping as any other palette. */
@Test
fun `the shipped fallback is fully mapped too`() {
val scheme = schemeOf(ProtonFallback)
assertEquals(Color(ProtonFallback.elevated.toInt()), scheme.surfaceContainerHighest)
assertEquals(Color(ProtonFallback.card.toInt()), scheme.surfaceContainer)
assertEquals(Color(ProtonFallback.dangerDim.toInt()), scheme.errorContainer)
}
}
@@ -0,0 +1,109 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* That the type ramp is the ramp, and the corner radius is the radius.
*
* The two clients share a palette because one Rust function resolves it for
* both. Type and shape have no such bridge — they are named in
* `pstr_app::theme::Role` and copied here — so what stops them drifting is this
* test and the numbers in it. Change the Rust ramp and this fails; that is the
* point of it.
*
* Every style is named rather than checked against a set, because "some rung"
* is not the property that matters: `bodyMedium`'s Material default is 14sp,
* which is a rung, so a `bodyMedium` nobody mapped would pass a looser check
* while sitting one step off where it belongs.
*/
class TypeRampTest {
/** `Role::size()`, in the order `theme.rs` declares it. */
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
private val expected = mapOf(
"displayLarge" to 26f,
"displayMedium" to 26f,
"displaySmall" to 20f,
"headlineLarge" to 26f,
"headlineMedium" to 26f,
"headlineSmall" to 18f,
"titleLarge" to 20f,
"titleMedium" to 17f,
"titleSmall" to 15f,
"bodyLarge" to 14f,
"bodyMedium" to 13f,
"bodySmall" to 12f,
"labelLarge" to 13f,
"labelMedium" to 12f,
"labelSmall" to 11f,
)
@Test
fun `every text style sits on the shared ramp`() {
expected.forEach { (name, size) ->
assertEquals(name, size, styles().getValue(name).fontSize.value, 0.001f)
}
}
/**
* Material adds type styles between versions, and one added tomorrow would
* arrive at whatever size Material chose for it. The list above is only as
* complete as the day it was written unless something says so.
*/
@Test
fun `no text style is left at Material's own size`() {
assertEquals(expected.keys, styles().keys)
}
/**
* Leading is derived from the size, so the only thing worth asserting is
* that it *was* derived — a style that kept Material's line height carries
* a number with no relation to its own size.
*/
@Test
fun `line height follows the size it belongs to`() {
styles().forEach { (name, style) ->
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
}
}
@Test
fun `the ramp has no rung the styles never reach`() {
val drawn = styles().values.map { it.fontSize.value }.toSet()
ramp.forEach { rung -> assertTrue("$rung is named but never drawn", rung in drawn) }
}
@Test
fun `every surface is drawn at the desktop's radius`() {
listOf(ProtonShapes.small, ProtonShapes.medium, ProtonShapes.large).forEach { shape ->
// `CornerSize` resolves against a size and a density, and neither is
// available to a unit test; `toString` is the only handle on the
// value that does not need a composition.
assertTrue("$shape is not radius $CORNER", shape.toString().contains("$CORNER"))
}
}
/**
* Every text style a caller can set, read by name, whatever the version
* names.
*
* Material 1.4 carries a second, `Emphasized` copy of all fifteen for its
* expressive components. Those are `internal` — the constructor takes them
* but Kotlin will not let this module pass them — so they keep Material's
* own sizes and nothing here can do anything about it. Their getters are
* name-mangled with `$material3`, which is how they are told apart from a
* public style that genuinely went unmapped.
*/
private fun styles(): Map<String, TextStyle> = Typography::class.java.declaredMethods
.filter { it.parameterCount == 0 && it.returnType == TextStyle::class.java }
.filterNot { '$' in it.name }
.associate { method ->
method.isAccessible = true
method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) to
method.invoke(ProtonTypography) as TextStyle
}
}
@@ -0,0 +1,11 @@
# Robolectric ships no Android image for API 36 yet, and defaults to the app's
# targetSdk, so host tests would fail to start. 34 is the newest it has and is
# well above minSdk 31; anything whose behaviour actually turns on the API level
# belongs in androidTest, on a real device, not here.
sdk=34
# ProtonStreamApplication.onCreate() calls System.loadLibrary("pstr_android"),
# which has no host build. Host tests get a plain Application; everything that
# needs the bridge is an instrumentation test by construction — see
# androidTest/.../UniFfiBoundaryTest.
application=android.app.Application
+13 -3
View File
@@ -6,10 +6,14 @@ activity = "1.11.0"
lifecycle = "2.9.4" lifecycle = "2.9.4"
navigation = "2.9.5" navigation = "2.9.5"
work = "2.10.5" work = "2.10.5"
media3 = "1.8.0"
adaptive = "1.1.0" adaptive = "1.1.0"
jna = "5.17.0" jna = "5.17.0"
junit = "4.13.2" junit = "4.13.2"
robolectric = "4.16"
androidx-test-core = "1.7.0"
androidx-test-junit = "1.3.0"
androidx-test-runner = "1.7.0"
ktlint-plugin = "13.1.0"
[libraries] [libraries]
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" } androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
@@ -18,8 +22,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
@@ -29,11 +32,18 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" } androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" } rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
+1 -1
View File
@@ -29,6 +29,7 @@ thiserror.workspace = true
uniffi.workspace = true uniffi.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
# Match rustls-platform-verifier's JNI types: Java objects from different # Match rustls-platform-verifier's JNI types: Java objects from different
@@ -36,4 +37,3 @@ serde_json.workspace = true
jni = "0.21.1" jni = "0.21.1"
rustls-platform-verifier = "0.6.2" rustls-platform-verifier = "0.6.2"
android_logger = "0.14" android_logger = "0.14"
log = "0.4"
File diff suppressed because it is too large. Load diff
+6
View File
@@ -2,3 +2,9 @@
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33). # UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime. # Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
disable_java_cleaner = true disable_java_cleaner = true
[bindings.swift]
# The iOS client (`ios/`) compiles the generated file straight into the app
# target and reaches the C side through its bridging header, so the names are
# what that header and `ios/project.yml` expect.
module_name = "PstrBridge"
+17
View File
@@ -24,8 +24,25 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
eframe.workspace = true eframe.workspace = true
egui.workspace = true egui.workspace = true
egui-phosphor.workspace = true
image.workspace = true image.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
directories.workspace = true
chrono.workspace = true
souvlaki.workspace = true
[dev-dependencies]
skrifa.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
dbus.workspace = true
[target.'cfg(windows)'.dependencies]
# Named pipes, for handing a second launch's request to the open window.
tokio = { workspace = true, features = ["net"] }
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
tauri-winrt-notification.workspace = true
raw-window-handle = "0.6"
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+6 -1
View File
@@ -92,7 +92,12 @@ impl Harness {
.context("eframe was built without a glow context")?; .context("eframe was built without a glow context")?;
// SAFETY: `CreationContext` is handed to us on the UI thread with the // SAFETY: `CreationContext` is handed to us on the UI thread with the
// context current, which is the same guarantee `App::ui` gives. // context current, which is the same guarantee `App::ui` gives.
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?; let video = unsafe {
VideoSurface::new(Arc::clone(&player), gl, {
let ctx = cc.egui_ctx.clone();
move || ctx.request_repaint()
})
}?;
Ok(Self { Ok(Self {
player, player,
+1224 -138
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
//! Settings only the desktop app has.
//!
//! Kept out of [`pstr_core::prefs::PlaybackPrefs`] on purpose: that one is
//! shared with Android through a bridge record, and a field Android neither
//! shows nor keeps would be reset to its default every time Android saved.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// What the settings page offers for the streaming cache, in GiB.
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
// A file from an older version, or edited by hand, missing a field: the rest
// still counts, and the missing one takes its default.
#[serde(default)]
pub struct DesktopPrefs {
/// Let mpv decode on the GPU. Off is for a driver that shows green frames
/// or nothing; it costs CPU, not correctness.
pub hardware_decoding: bool,
/// How much decrypted video the disk cache may keep.
pub cache_gib: u32,
}
impl Default for DesktopPrefs {
fn default() -> Self {
Self {
hardware_decoding: true,
cache_gib: 4,
}
}
}
impl DesktopPrefs {
pub fn cache_bytes(&self) -> u64 {
u64::from(self.cache_gib.clamp(1, 64)) << 30
}
}
/// The prefs and where they live.
pub struct DesktopPrefsFile {
path: PathBuf,
/// False when the file was there and would not parse. Such a file is never
/// overwritten — it is far more likely a bug than something to replace —
/// so changes then last only for the session.
writable: bool,
}
impl DesktopPrefsFile {
pub fn load(path: PathBuf) -> (Self, DesktopPrefs) {
match pstr_core::config::read_json::<DesktopPrefs>(&path) {
Ok(prefs) => (
Self {
path,
writable: true,
},
prefs.unwrap_or_default(),
),
Err(error) => {
tracing::warn!("read the desktop settings: {error}");
(
Self {
path,
writable: false,
},
DesktopPrefs::default(),
)
}
}
}
pub fn save(&self, prefs: &DesktopPrefs) -> pstr_core::Result<()> {
if !self.writable {
tracing::warn!(
"not saving the desktop settings over {}, which would not parse",
self.path.display()
);
return Ok(());
}
pstr_core::config::write_json(&self.path, prefs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_missing_a_field_keeps_the_rest() {
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
assert_eq!(prefs.cache_gib, 8);
assert!(prefs.hardware_decoding);
}
#[test]
fn an_unparseable_file_is_never_overwritten() {
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
std::fs::write(&path, "{ not json").unwrap();
let (file, prefs) = DesktopPrefsFile::load(path.clone());
assert_eq!(prefs, DesktopPrefs::default());
file.save(&DesktopPrefs::default()).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
let _ = std::fs::remove_file(path);
}
}
File diff suppressed because it is too large. Load diff
+185
View File
@@ -0,0 +1,185 @@
//! Keeping the screen on while a film plays.
//!
//! mpv does this itself for a window of its own (`stop-screensaver`), but here
//! the picture is drawn into the app's window through the render API, and mpv
//! has no window to hold the screensaver off for. So the app asks.
//!
//! Best effort, like the media controls: a desktop that does not answer means
//! the screen may blank during a film, which is what happened before this.
/// Holds the screensaver off while [`Inhibitor::set`] says so.
pub struct Inhibitor {
held: bool,
#[cfg(target_os = "linux")]
orders: Option<std::sync::mpsc::Sender<bool>>,
}
impl Inhibitor {
pub fn new() -> Self {
Self {
held: false,
#[cfg(target_os = "linux")]
orders: linux::spawn(),
}
}
/// Hold or release. Called every frame; acts only on a change.
pub fn set(&mut self, hold: bool) {
if hold == self.held {
return;
}
self.held = hold;
#[cfg(target_os = "linux")]
if let Some(orders) = &self.orders {
let _ = orders.send(hold);
}
#[cfg(windows)]
windows::set(hold);
}
}
impl Default for Inhibitor {
fn default() -> Self {
Self::new()
}
}
impl Drop for Inhibitor {
fn drop(&mut self) {
self.set(false);
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::sync::mpsc::{Sender, channel};
use std::time::Duration;
use dbus::Path;
use dbus::arg::PropMap;
use dbus::blocking::Connection;
const TIMEOUT: Duration = Duration::from_secs(2);
const REASON: &str = "Playing a video";
/// What is holding the screensaver off, and how to let go of it.
enum Hold {
/// The desktop portal: released by closing the request it returned.
Portal(Path<'static>),
/// The older `org.freedesktop.ScreenSaver`: released by its cookie.
ScreenSaver(u32),
}
/// A thread of its own, holding the session bus connection. The hold is
/// tied to that connection — closing it releases the hold — so it has to
/// outlive any one call, and a D-Bus call that stalls must not stall a
/// frame.
pub fn spawn() -> Option<Sender<bool>> {
let (orders, receiver) = channel::<bool>();
std::thread::Builder::new()
.name("inhibit".into())
.spawn(move || {
let connection = match Connection::new_session() {
Ok(connection) => connection,
Err(error) => {
tracing::debug!("no session bus to inhibit the screensaver on: {error}");
return;
}
};
let mut hold = None;
for wanted in receiver {
match (wanted, hold.take()) {
(true, None) => hold = acquire(&connection),
(false, Some(held)) => release(&connection, held),
(_, held) => hold = held,
}
}
if let Some(held) = hold {
release(&connection, held);
}
})
.inspect_err(|error| tracing::debug!("inhibit thread: {error}"))
.ok()?;
Some(orders)
}
fn acquire(connection: &Connection) -> Option<Hold> {
// The portal first: it is what works under Flatpak and on GNOME, which
// does not implement the screensaver interface's `Inhibit`.
let portal = connection.with_proxy(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
TIMEOUT,
);
let mut options = PropMap::new();
options.insert(
"reason".into(),
dbus::arg::Variant(Box::new(REASON.to_owned())),
);
// 8 is "idle": the screen blanking or locking for want of input.
let answer: Result<(Path<'static>,), _> = portal.method_call(
"org.freedesktop.portal.Inhibit",
"Inhibit",
("", 8u32, options),
);
match answer {
Ok((request,)) => return Some(Hold::Portal(request)),
Err(error) => tracing::debug!("portal inhibit: {error}"),
}
let screensaver = connection.with_proxy(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
TIMEOUT,
);
let answer: Result<(u32,), _> = screensaver.method_call(
"org.freedesktop.ScreenSaver",
"Inhibit",
("proton-stream", REASON),
);
match answer {
Ok((cookie,)) => Some(Hold::ScreenSaver(cookie)),
Err(error) => {
tracing::debug!("screensaver inhibit: {error}");
None
}
}
}
fn release(connection: &Connection, hold: Hold) {
let result: Result<(), dbus::Error> = match hold {
Hold::Portal(request) => connection
.with_proxy("org.freedesktop.portal.Desktop", request, TIMEOUT)
.method_call("org.freedesktop.portal.Request", "Close", ()),
Hold::ScreenSaver(cookie) => connection
.with_proxy(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
TIMEOUT,
)
.method_call("org.freedesktop.ScreenSaver", "UnInhibit", (cookie,)),
};
if let Err(error) = result {
tracing::debug!("release the screensaver: {error}");
}
}
}
#[cfg(windows)]
mod windows {
use windows_sys::Win32::System::Power::{
ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, SetThreadExecutionState,
};
pub fn set(hold: bool) {
let flags = if hold {
ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED
} else {
ES_CONTINUOUS
};
// SAFETY: takes flags by value and touches nothing of ours.
unsafe {
SetThreadExecutionState(flags);
}
}
}
+350
View File
@@ -0,0 +1,350 @@
//! One window at a time.
//!
//! A second launch hands what it was started with — a share link, or nothing
//! — to the window already open, and exits. Without this, a second click on
//! the launcher opened a second window on the same catalog: two engines
//! writing the same watch state, two crawls of the same shares, and two films
//! playing over each other.
//!
//! The channel between the two is a Unix socket in the user's runtime
//! directory, or a named pipe on Windows. Anything that goes wrong setting it
//! up is logged and the app runs on without it: a second window is a nuisance,
//! and a launch that refuses to start because of this is a bug.
use std::sync::mpsc::{Receiver, Sender, channel};
use pstr_core::config::AppDirs;
/// What a later launch asks of the window already open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
/// Come to the front.
Raise,
/// Come to the front, with this share link in the Add form.
AddShare(String),
}
impl Request {
/// What a launch was started with: the first argument that is a share
/// link, so `proton-stream 'https://drive.proton.me/urls/…#…'` adds it.
pub fn from_args(args: impl IntoIterator<Item = String>) -> Self {
args.into_iter()
.map(|arg| arg.trim().to_owned())
.find(|arg| pstr_core::shares::share_token(arg).is_ok())
.map_or(Self::Raise, Self::AddShare)
}
/// One line on the wire.
fn encode(&self) -> String {
match self {
Self::Raise => "raise\n".to_owned(),
Self::AddShare(url) => format!("add {url}\n"),
}
}
/// The other end of [`Request::encode`]. Anything else is ignored rather
/// than guessed at — and a link that is not a share link is not one to
/// put in the form.
fn decode(line: &str) -> Option<Self> {
let line = line.trim();
if line == "raise" {
return Some(Self::Raise);
}
let url = line.strip_prefix("add ")?.trim();
pstr_core::shares::share_token(url)
.is_ok()
.then(|| Self::AddShare(url.to_owned()))
}
}
/// How long a launch spends handing its request over before giving up on it.
const HAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// The most a request may be. A share link is a few hundred bytes.
const MOST: u64 = 8 * 1024;
/// The outcome of [`claim`].
pub enum Claim {
/// This is the window. The listener answers later launches.
First(Listener),
/// A window was already open and has the request. This launch is done.
Handed,
}
/// Become the one window, or hand `request` to the one there is.
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
platform::claim(runtime, dirs, request)
}
/// Requests from later launches, until the process ends.
pub struct Listener {
inner: Option<platform::Inner>,
}
impl Listener {
/// Start answering. `first` is this launch's own request, which arrives
/// through the same channel as the rest so there is one place to act on it.
pub fn serve(
self,
runtime: &tokio::runtime::Runtime,
ctx: &egui::Context,
first: Request,
) -> Receiver<Request> {
let (sender, receiver) = channel();
if first != Request::Raise {
let _ = sender.send(first);
}
if let Some(inner) = self.inner {
platform::serve(
inner,
runtime,
Deliver {
sender,
ctx: ctx.clone(),
},
);
}
receiver
}
}
/// Where a received request goes: the app's queue, and a repaint so it is
/// acted on now rather than when the pointer next moves.
struct Deliver {
sender: Sender<Request>,
ctx: egui::Context,
}
impl Deliver {
fn line(&self, text: &str) {
let Some(request) = Request::decode(text) else {
tracing::debug!("instance: ignored a request that did not parse");
return;
};
let _ = self.sender.send(request);
self.ctx.request_repaint();
}
}
#[cfg(unix)]
mod platform {
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
pub struct Inner(UnixListener);
/// In the runtime directory where there is one: it is the user's alone and
/// emptied at logout, which is exactly the life a socket should have.
fn socket(dirs: &AppDirs) -> PathBuf {
directories::BaseDirs::new()
.and_then(|base| base.runtime_dir().map(std::path::Path::to_path_buf))
.unwrap_or_else(|| dirs.cache.clone())
.join("io.narl.proton-stream.sock")
}
pub fn claim(_: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
let path = socket(dirs);
if let Ok(mut stream) = UnixStream::connect(&path) {
let _ = stream.set_write_timeout(Some(HAND_TIMEOUT));
match stream.write_all(request.encode().as_bytes()) {
Ok(()) => return Claim::Handed,
Err(error) => tracing::warn!("instance: hand over to the open window: {error}"),
}
}
// Nobody answered, so a socket file still there is left over from a
// window that did not exit cleanly.
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(listener) => listener,
Err(error) => {
tracing::warn!("instance: listen at {}: {error}", path.display());
return Claim::First(Listener { inner: None });
}
};
// The runtime directory is private already; the cache fallback is
// not necessarily, and a share link is a secret.
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
Claim::First(Listener {
inner: Some(Inner(listener)),
})
}
pub fn serve(inner: Inner, _: &tokio::runtime::Runtime, deliver: Deliver) {
let spawned = std::thread::Builder::new()
.name("instance".into())
.spawn(move || {
for stream in inner.0.incoming() {
let Ok(stream) = stream else { continue };
let _ = stream.set_read_timeout(Some(HAND_TIMEOUT));
let mut text = String::new();
if stream.take(MOST).read_to_string(&mut text).is_ok() {
deliver.line(&text);
}
}
});
if let Err(error) = spawned {
tracing::warn!("instance thread: {error}");
}
}
}
#[cfg(windows)]
mod platform {
use std::hash::{BuildHasher, Hasher};
use std::io::Write;
use tokio::io::AsyncReadExt;
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
pub struct Inner {
name: String,
first: NamedPipeServer,
}
/// The pipe's name. Pipe names are machine-wide, so a fixed one could be
/// taken first by another account on the same machine, which would then
/// be handed this user's share links. The name carries a random part kept
/// in this user's own config directory, which nobody else can read.
fn pipe(dirs: &AppDirs) -> String {
let path = dirs.config.join("instance.key");
let key = std::fs::read_to_string(&path)
.ok()
.map(|key| key.trim().to_owned())
.filter(|key| key.len() == 32 && key.bytes().all(|b| b.is_ascii_hexdigit()))
.unwrap_or_else(|| {
// `RandomState` is seeded from the OS's random source, once
// per process and stepped per instance after that.
let mut key = String::new();
for _ in 0..2 {
let word = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
key.push_str(&format!("{word:016x}"));
}
if let Err(error) = std::fs::write(&path, &key) {
tracing::warn!("instance: store the pipe key: {error}");
}
key
});
format!(r"\\.\pipe\io.narl.proton-stream.{key}")
}
fn server(name: &str, first: bool) -> std::io::Result<NamedPipeServer> {
ServerOptions::new()
.first_pipe_instance(first)
.reject_remote_clients(true)
.access_outbound(false)
.create(name)
}
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
let name = pipe(dirs);
// Registering a pipe with tokio needs its reactor.
let _entered = runtime.enter();
match server(&name, true) {
Ok(first) => Claim::First(Listener {
inner: Some(Inner { name, first }),
}),
// Taken: there is a window. Hand it the request.
Err(_) => {
let started = std::time::Instant::now();
loop {
match std::fs::OpenOptions::new().write(true).open(&name) {
Ok(mut pipe) => {
if let Err(error) = pipe.write_all(request.encode().as_bytes()) {
tracing::warn!("instance: hand over to the open window: {error}");
}
return Claim::Handed;
}
// Every instance busy with another launch: wait a turn.
Err(_) if started.elapsed() < HAND_TIMEOUT => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(error) => {
tracing::warn!("instance: reach the open window: {error}");
return Claim::First(Listener { inner: None });
}
}
}
}
}
}
pub fn serve(inner: Inner, runtime: &tokio::runtime::Runtime, deliver: Deliver) {
runtime.spawn(async move {
let Inner { name, mut first } = inner;
loop {
if first.connect().await.is_err() {
continue;
}
let connected = first;
// The next instance is up before this one is read, so a launch
// right behind this one finds a pipe to connect to.
first = match server(&name, false) {
Ok(next) => next,
Err(error) => {
tracing::warn!("instance: reopen the pipe: {error}");
return;
}
};
let mut text = String::new();
let mut limited = connected.take(MOST);
let read = limited.read_to_string(&mut text);
if let Ok(Ok(_)) = tokio::time::timeout(HAND_TIMEOUT, read).await {
deliver.line(&text);
}
}
});
}
}
#[cfg(not(any(unix, windows)))]
mod platform {
use super::{AppDirs, Claim, Deliver, Listener, Request};
pub struct Inner;
pub fn claim(_: &tokio::runtime::Runtime, _: &AppDirs, _: &Request) -> Claim {
Claim::First(Listener { inner: None })
}
pub fn serve(_: Inner, _: &tokio::runtime::Runtime, _: Deliver) {}
}
#[cfg(test)]
mod tests {
use super::*;
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#fragmentsecret";
#[test]
fn a_launch_with_a_share_link_asks_for_it_to_be_added() {
let args = ["--verbose".to_owned(), format!(" {LINK} ")];
assert_eq!(Request::from_args(args), Request::AddShare(LINK.to_owned()));
}
#[test]
fn a_launch_with_no_share_link_only_raises_the_window() {
let args = ["https://example.com/".to_owned()];
assert_eq!(Request::from_args(args), Request::Raise);
assert_eq!(Request::from_args(Vec::new()), Request::Raise);
}
#[test]
fn a_request_survives_the_wire() {
for request in [Request::Raise, Request::AddShare(LINK.to_owned())] {
assert_eq!(Request::decode(&request.encode()), Some(request));
}
}
#[test]
fn a_line_that_is_not_a_request_is_ignored() {
assert_eq!(Request::decode("add https://example.com/"), None);
assert_eq!(Request::decode("open the pod bay doors"), None);
}
}
+7
View File
@@ -20,8 +20,15 @@
//! `examples/embedded_video.rs`, which is the smoke test for [`video`]. //! `examples/embedded_video.rs`, which is the smoke test for [`video`].
pub mod app; pub mod app;
pub mod desktop_prefs;
pub mod engine; pub mod engine;
pub mod inhibit;
pub mod instance;
pub mod media;
pub mod notify;
pub mod pacing;
pub mod playback; pub mod playback;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;
pub mod video; pub mod video;
pub mod window;
+28 -4
View File
@@ -31,20 +31,44 @@ fn main() -> anyhow::Result<()> {
); );
let dirs = AppDirs::ensure().context("resolve app directories")?; let dirs = AppDirs::ensure().context("resolve app directories")?;
let options = eframe::NativeOptions { // Before the window: a second launch hands its request to the first and
viewport: egui::ViewportBuilder::default() // never opens one.
let request = pstr_app::instance::Request::from_args(std::env::args().skip(1));
let instance = match pstr_app::instance::claim(&runtime, &dirs, &request) {
pstr_app::instance::Claim::First(listener) => listener,
pstr_app::instance::Claim::Handed => {
tracing::info!("handed over to the window already open");
return Ok(());
}
};
let window = pstr_app::window::WindowState::load(&dirs.window_file());
let mut viewport = egui::ViewportBuilder::default();
// X11 and Windows take the icon from the window. Wayland ignores this and
// looks the app id up in the installed desktop entry instead.
match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) {
Ok(icon) => viewport = viewport.with_icon(icon),
Err(error) => tracing::debug!("window icon: {error}"),
}
let mut options = eframe::NativeOptions {
viewport: viewport
.with_title("proton-stream") .with_title("proton-stream")
.with_inner_size([1180.0, 780.0]) .with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
.with_maximized(window.is_some_and(|state| state.maximized))
.with_min_inner_size([720.0, 480.0]) .with_min_inner_size([720.0, 480.0])
.with_app_id("io.narl.proton-stream"), .with_app_id("io.narl.proton-stream"),
..Default::default() ..Default::default()
}; };
// Off on Wayland, where a swap on a window nobody is looking at never
// returns and the compositor decides the app has hung. `pstr_app::pacing`
// has the whole of it, and caps the frame rate in vsync's place.
options.glow_options.vsync = pstr_app::pacing::vsync();
eframe::run_native( eframe::run_native(
"proton-stream", "proton-stream",
options, options,
Box::new(move |cc| { Box::new(move |cc| {
pstr_app::app::App::new(cc, runtime, dirs) pstr_app::app::App::new(cc, runtime, dirs, instance, request)
.map(|app| Box::new(app) as Box<dyn eframe::App>) .map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(|error| error.into()) .map_err(|error| error.into())
}), }),
+170
View File
@@ -0,0 +1,170 @@
//! The desktop's view of what is playing: media keys, the media widget in the
//! panel, the lock screen.
//!
//! MPRIS on Linux, the System Media Transport Controls on Windows, through
//! `souvlaki`. Both are optional in the strongest sense — a session with no
//! D-Bus, or a desktop with nothing listening, plays exactly as before — so
//! every failure here is a debug line and nothing more.
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
SeekDirection,
};
use crate::app::{Action, Adjacent};
use crate::playback::{Command, Playback};
/// How far one press of a seek key moves, when the desktop does not say.
const SEEK_SECONDS: f64 = 10.0;
/// What was last told to the desktop, so it is told again only on a change.
#[derive(Debug, Clone, PartialEq)]
struct Told {
/// What the metadata was built from: the player, whether its duration was
/// known yet, and the cover. Any of them changing means sending it again.
item: Option<(u64, bool, Option<String>)>,
paused: bool,
/// Where the playhead was, to the second. A desktop interpolates between
/// updates, so this only has to be sent when the playhead jumps.
second: i64,
}
pub struct MediaSession {
controls: Option<MediaControls>,
events: Receiver<MediaControlEvent>,
told: Option<Told>,
}
impl MediaSession {
/// Register with the desktop. `window` is the handle SMTC needs on
/// Windows; elsewhere it is not used.
pub fn new(ctx: &egui::Context, window: Option<*mut std::ffi::c_void>) -> Self {
let (sender, events) = channel();
let config = PlatformConfig {
display_name: "proton-stream",
dbus_name: "proton_stream",
hwnd: window,
};
let ctx = ctx.clone();
let controls = MediaControls::new(config)
.and_then(|mut controls| {
controls.attach(move |event| {
// The press arrives on the D-Bus thread; it is acted on in
// the next frame, which this is what asks for.
let _ = sender.send(event);
ctx.request_repaint();
})?;
Ok(controls)
})
.inspect_err(|error| tracing::debug!("media controls unavailable: {error:?}"))
.ok();
Self {
controls,
events,
told: None,
}
}
/// Turn the presses since the last frame into actions.
pub fn drain(&mut self, playback: Option<&Playback>, actions: &mut Vec<Action>) {
while let Ok(event) = self.events.try_recv() {
if let Some(action) = action_for(event, playback) {
actions.push(action);
}
}
}
/// Tell the desktop what is playing, if that changed since the last frame.
///
/// `cover` is a `file://` URL to the title's artwork when it is on disk.
pub fn show(&mut self, playback: Option<&Playback>, cover: Option<&str>) {
let Some(controls) = self.controls.as_mut() else {
return;
};
let told = Told {
item: playback.map(|playback| {
(
playback.id,
playback.duration.is_some(),
cover.map(str::to_owned),
)
}),
paused: playback.is_none_or(|playback| playback.paused),
second: playback.map_or(0, |playback| playback.position as i64),
};
let previous = self.told.replace(told.clone());
// Within the second, or one second on from it: playback carrying on,
// which the desktop already shows without being told.
if let Some(previous) = &previous
&& previous.item == told.item
&& previous.paused == told.paused
&& (0..=1).contains(&(told.second - previous.second))
{
return;
}
let Some(playback) = playback else {
if let Err(error) = controls.set_playback(MediaPlayback::Stopped) {
tracing::debug!("media controls: {error:?}");
}
return;
};
if previous.is_none_or(|previous| previous.item != told.item) {
let target = &playback.target;
let episode = target.caption();
let metadata = MediaMetadata {
title: Some(if episode.is_empty() {
&target.title_name
} else {
&episode
}),
album: Some(&target.title_name),
artist: None,
cover_url: cover,
duration: playback.duration.map(Duration::from_secs_f64),
};
if let Err(error) = controls.set_metadata(metadata) {
tracing::debug!("media controls: {error:?}");
}
}
let progress = Some(MediaPosition(Duration::from_secs_f64(
playback.position.max(0.0),
)));
let state = if playback.paused {
MediaPlayback::Paused { progress }
} else {
MediaPlayback::Playing { progress }
};
if let Err(error) = controls.set_playback(state) {
tracing::debug!("media controls: {error:?}");
}
}
}
/// What a press means here. `None` for the ones with nothing to act on.
fn action_for(event: MediaControlEvent, playback: Option<&Playback>) -> Option<Action> {
let playback = playback?;
let seek = |direction: SeekDirection, seconds: f64| {
Action::Player(Command::SeekBy(match direction {
SeekDirection::Forward => seconds,
SeekDirection::Backward => -seconds,
}))
};
Some(match event {
MediaControlEvent::Toggle => Action::Player(Command::TogglePause),
MediaControlEvent::Play if playback.paused => Action::Player(Command::TogglePause),
MediaControlEvent::Pause if !playback.paused => Action::Player(Command::TogglePause),
MediaControlEvent::Next => Action::PlayAdjacent(Adjacent::Next),
MediaControlEvent::Previous => Action::PlayAdjacent(Adjacent::Previous),
MediaControlEvent::Stop => Action::LeavePlayer,
MediaControlEvent::Seek(direction) => seek(direction, SEEK_SECONDS),
MediaControlEvent::SeekBy(direction, by) => seek(direction, by.as_secs_f64()),
MediaControlEvent::SetPosition(MediaPosition(at)) => {
Action::Player(Command::SeekTo(at.as_secs_f64()))
}
_ => return None,
})
}
+190
View File
@@ -0,0 +1,190 @@
//! Desktop notifications, for things that finish while nobody is looking.
//!
//! Only when the window does not have focus: a viewer who is looking at the
//! app already has the toast, and a second copy of it from the panel is noise.
//! That rule is the caller's — see `App::notify`.
//!
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
//! macOS yet.
//!
//! A Windows toast is shown under an application id, and Windows drops a toast
//! whose id it does not know without a word. The id is registered for the
//! current user under `HKCU\Software\Classes\AppUserModelId` — the same
//! registration the Windows App SDK makes for an app with no package — which
//! works for the MSI and the portable zip alike. If that write fails, the
//! toast goes out under PowerShell's id instead, which Windows always knows: a
//! notification that names the wrong program beats one that never appears.
use std::path::Path;
/// Sends notifications from a thread of its own, so a notification daemon
/// that is slow to answer never costs a frame.
pub struct Notifier {
#[cfg(any(target_os = "linux", windows))]
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
}
impl Notifier {
/// `cache` is where Windows is pointed for the toast's icon.
pub fn new(cache: &Path) -> Self {
let _ = cache;
Self {
#[cfg(target_os = "linux")]
orders: linux::spawn(),
#[cfg(windows)]
orders: windows::spawn(cache.join("notification-icon.png")),
}
}
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
#[cfg(any(target_os = "linux", windows))]
if let Some(orders) = &self.orders {
let _ = orders.send((summary.into(), body.into()));
}
#[cfg(not(any(target_os = "linux", windows)))]
let _ = (summary.into(), body.into());
}
}
#[cfg(target_os = "linux")]
mod linux {
use std::sync::mpsc::{Sender, channel};
use std::time::Duration;
use dbus::arg::PropMap;
use dbus::blocking::Connection;
pub fn spawn() -> Option<Sender<(String, String)>> {
let (orders, receiver) = channel::<(String, String)>();
std::thread::Builder::new()
.name("notify".into())
.spawn(move || {
// Connected on first use rather than at startup: most sessions
// never send one.
let mut connection: Option<Connection> = None;
for (summary, body) in receiver {
if connection.is_none() {
connection = Connection::new_session()
.inspect_err(|error| tracing::debug!("notifications: {error}"))
.ok();
}
let Some(connection) = &connection else {
continue;
};
let proxy = connection.with_proxy(
"org.freedesktop.Notifications",
"/org/freedesktop/Notifications",
Duration::from_secs(2),
);
let answer: Result<(u32,), _> = proxy.method_call(
"org.freedesktop.Notifications",
"Notify",
(
"proton-stream",
0u32,
"io.narl.proton-stream",
summary,
body,
Vec::<String>::new(),
PropMap::new(),
-1i32,
),
);
if let Err(error) = answer {
tracing::debug!("notification: {error}");
}
}
})
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
.ok()?;
Some(orders)
}
}
#[cfg(windows)]
mod windows {
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Sender, channel};
use tauri_winrt_notification::Toast;
/// The id toasts are shown under, and the key it is registered at.
const APP_ID: &str = "io.narl.proton-stream";
const ICON: &[u8] = include_bytes!("../assets/icon-256.png");
pub fn spawn(icon: PathBuf) -> Option<Sender<(String, String)>> {
let (orders, receiver) = channel::<(String, String)>();
std::thread::Builder::new()
.name("notify".into())
.spawn(move || {
// Registered on first use rather than at startup: most
// sessions never send one.
let mut app_id: Option<&str> = None;
for (summary, body) in receiver {
let id = *app_id.get_or_insert_with(|| {
let _ = std::fs::write(&icon, ICON);
if register(&icon) {
APP_ID
} else {
Toast::POWERSHELL_APP_ID
}
});
let shown = Toast::new(id).title(&summary).text1(&body).show();
if let Err(error) = shown {
tracing::debug!("notification: {error}");
}
}
})
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
.ok()?;
Some(orders)
}
/// Tell Windows the app id's display name and icon. Per user, no rights
/// needed, and written again every session so a moved install points at
/// a live icon.
fn register(icon: &Path) -> bool {
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
RegCreateKeyExW, RegSetValueExW,
};
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(std::iter::once(0)).collect()
}
let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
let mut key: HKEY = std::ptr::null_mut();
// SAFETY: every pointer is to a live, NUL-terminated buffer or an out
// parameter on this stack frame, and the key is closed before return.
unsafe {
let created = RegCreateKeyExW(
HKEY_CURRENT_USER,
path.as_ptr(),
0,
std::ptr::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
);
if created != 0 {
tracing::debug!("register the notification id: error {created}");
return false;
}
let icon = icon.to_string_lossy();
let mut written = true;
for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] {
let name = wide(name);
let value = wide(value);
let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX);
let set =
RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes);
written &= set == 0;
}
RegCloseKey(key);
written
}
}
}
+186
View File
@@ -0,0 +1,186 @@
//! How often the window is allowed to draw — and why it does not simply let
//! vsync decide on Wayland.
//!
//! ## The bug this exists for
//!
//! A Wayland compositor throttles a client by *withholding* the `wl_surface`
//! frame callback, and it withholds it for as long as the surface is not being
//! shown — a window on another workspace, or behind a fullscreen one. Mesa's
//! EGL implements a swap interval of 1 by waiting on exactly that callback, so
//! `eglSwapBuffers` on a hidden surface does not return late, it does not
//! return at all:
//!
//! ```text
//! eframe run_ui_and_paint ─▶ glutin swap_buffers ─▶ eglSwapBuffers
//! │
//! wl_display_dispatch_queue ◀─┘ ppoll, forever
//! ```
//!
//! That is the whole of the UI thread, and the same thread is the one that
//! answers `xdg_wm_base.ping`. Hyprland pings a client it has not heard from
//! and puts up an "application is not responding" dialog when the pong does not
//! come, which is what a viewer sees a few seconds after switching away from a
//! playing window. Nothing is actually wrong — coming back releases the frame
//! callback and the app carries on — but a dialog offering to kill the player
//! is not an acceptable way to say "this window is not visible".
//!
//! winit cannot rescue this for us: `WindowEvent::Occluded` is X11, macOS and
//! web only — its Wayland implementation was deliberately removed in 0.29 — so
//! eframe never learns the window is hidden and keeps painting it.
//!
//! ## What is done instead
//!
//! On Wayland the swap interval is set to 0 ([`vsync`]), which makes
//! `eglSwapBuffers` return immediately whether the surface is being shown or
//! not, and the frame rate is capped here instead ([`Pacer`]). Nothing is lost
//! by dropping vsync there: a Wayland compositor composites from the last
//! buffer a client committed, at its own refresh, so a client that commits too
//! often wastes work but cannot tear. X11 and Windows keep vsync, where a
//! client really does drive the scanout and hidden windows do not block.
//!
//! The cap is a ceiling and not a rate: frames are drawn when something asks
//! for one — a new mpv frame, an animation, a keystroke — so an idle window
//! still draws nothing, and a 24 fps film still draws 24 times a second.
use std::time::{Duration, Instant};
/// The default ceiling, in frames a second.
///
/// Above the rate of any video this plays and of every animation in the app.
/// It is deliberately not the display's refresh rate, which neither eframe nor
/// egui gives us — a window on a 144 Hz panel therefore animates at 60 while
/// vsync is off. [`CAP_VAR`] is the way out for someone who minds.
const DEFAULT_CAP: f32 = 60.0;
/// Overrides [`DEFAULT_CAP`]. `0` removes the ceiling altogether.
const CAP_VAR: &str = "PSTR_FRAME_CAP";
/// Whether this process is talking to a Wayland compositor, decided the way
/// winit decides it: either variable, non-empty, Wayland first.
pub fn on_wayland() -> bool {
["WAYLAND_DISPLAY", "WAYLAND_SOCKET"]
.iter()
.any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()))
}
/// Whether to ask for a swap interval of 1. False on Wayland — see the module
/// documentation.
pub fn vsync() -> bool {
!on_wayland()
}
/// The ceiling to pace to, or `None` when vsync is doing the pacing.
fn cap() -> Option<f32> {
if vsync() {
return None;
}
let Ok(value) = std::env::var(CAP_VAR) else {
return Some(DEFAULT_CAP);
};
// Not a number, or a negative one, is a typo rather than a request to
// free-run — free-running is what `0` says, explicitly.
let parsed = value
.trim()
.parse::<f32>()
.ok()
.filter(|fps| fps.is_finite() && *fps >= 0.0);
match parsed {
Some(fps) if fps > 0.0 => Some(fps),
Some(_) => None,
None => {
tracing::warn!("{CAP_VAR}={value:?} is not a frame rate; using {DEFAULT_CAP}");
Some(DEFAULT_CAP)
}
}
}
/// Holds the draw loop to a maximum rate.
///
/// Sleeping is the only thing that can do this: a frame is under way by the
/// time the app sees it, and every other source of repaints — mpv, egui's own
/// animations — asks for the *next* one immediately. The sleep is at most one
/// frame's worth, which is far inside the several seconds a compositor waits
/// for a pong.
pub struct Pacer {
interval: Option<Duration>,
last: Instant,
}
impl Default for Pacer {
fn default() -> Self {
Self::new()
}
}
impl Pacer {
/// The pacer this session wants: capped when vsync is off, a no-op when it
/// is on.
pub fn new() -> Self {
Self::with_cap(cap())
}
fn with_cap(fps: Option<f32>) -> Self {
Self {
interval: fps.map(|fps| Duration::from_secs_f32(1.0 / fps)),
last: Instant::now(),
}
}
/// Whether this pacer does anything at all.
pub fn is_capped(&self) -> bool {
self.interval.is_some()
}
/// Called once at the top of a frame: sleeps out whatever is left of the
/// last one's budget.
pub fn wait(&mut self) {
let Some(interval) = self.interval else {
return;
};
let elapsed = self.last.elapsed();
if let Some(left) = interval.checked_sub(elapsed) {
std::thread::sleep(left);
}
self.last = Instant::now();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_uncapped_pacer_never_sleeps() {
let mut pacer = Pacer::with_cap(None);
assert!(!pacer.is_capped());
let before = Instant::now();
pacer.wait();
pacer.wait();
assert!(before.elapsed() < Duration::from_millis(50));
}
#[test]
fn a_capped_pacer_holds_the_second_frame_back() {
let mut pacer = Pacer::with_cap(Some(50.0));
assert!(pacer.is_capped());
pacer.wait();
let before = Instant::now();
pacer.wait();
// 20 ms apart, less whatever the first call spent. Sleeping is only
// ever long, so the lower bound is the one worth asserting on.
assert!(
before.elapsed() >= Duration::from_millis(10),
"second frame was not held back: {:?}",
before.elapsed()
);
}
#[test]
fn a_frame_that_took_longer_than_the_budget_is_not_delayed_further() {
let mut pacer = Pacer::with_cap(Some(1000.0));
std::thread::sleep(Duration::from_millis(5));
let before = Instant::now();
pacer.wait();
assert!(before.elapsed() < Duration::from_millis(2));
}
}
+110 -8
View File
@@ -110,6 +110,16 @@ impl PlaybackTarget {
/// The line under the title while this plays: the numbering, and the /// The line under the title while this plays: the numbering, and the
/// episode's name when a provider has given one. /// episode's name when a provider has given one.
/// How to name this file to someone not looking at its title page:
/// the title and the episode, or the file name when there is no title.
pub fn caption_or_name(&self) -> String {
match (self.title_name.is_empty(), self.caption()) {
(false, caption) if !caption.is_empty() => format!("{} — {caption}", self.title_name),
(false, _) => self.title_name.clone(),
(true, _) => self.name.clone(),
}
}
pub fn caption(&self) -> String { pub fn caption(&self) -> String {
match &self.episode_name { match &self.episode_name {
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle), Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
@@ -120,7 +130,7 @@ impl PlaybackTarget {
} }
/// What the UI can ask the player to do. /// What the UI can ask the player to do.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
TogglePause, TogglePause,
SeekBy(f64), SeekBy(f64),
@@ -134,6 +144,13 @@ pub enum Command {
/// every file it loads afterwards, so picking Japanese audio for one /// every file it loads afterwards, so picking Japanese audio for one
/// episode picks it for the next one too. /// episode picks it for the next one too.
SelectTrack(TrackKind, Option<i64>), SelectTrack(TrackKind, Option<i64>),
/// Move subtitles, as a percentage of the window's height. See
/// [`Playback::lift_subtitles`].
SetSubtitlePosition(f64),
/// 1.0 is normal speed.
SetSpeed(f64),
/// Save the current frame to this file.
Screenshot(std::path::PathBuf),
Stop, Stop,
} }
@@ -162,6 +179,11 @@ pub struct Playback {
/// 0–100, as mpv has it. /// 0–100, as mpv has it.
pub volume: f64, pub volume: f64,
pub muted: bool, pub muted: bool,
/// Playback speed, 1.0 being normal.
pub speed: f64,
/// Where subtitles were last put, so they are only moved when that changes
/// rather than once a frame.
subtitle_position: f64,
/// What the file contains. Empty until mpv has demuxed it. /// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>, pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them. /// The file's chapters, in order. Empty for a file muxed without them.
@@ -179,6 +201,10 @@ pub struct Playback {
/// A seek was issued and the picture has not come back yet — the thing /// A seek was issued and the picture has not come back yet — the thing
/// worth showing a spinner for on a link this slow. /// worth showing a spinner for on a link this slow.
pub seeking: bool, pub seeking: bool,
/// The end of what mpv has read ahead, in seconds from the start.
pub buffered: Option<f64>,
/// Playback is held waiting for data.
pub buffering: bool,
} }
impl Playback { impl Playback {
@@ -196,10 +222,9 @@ impl Playback {
target: PlaybackTarget, target: PlaybackTarget,
stream: VideoStream, stream: VideoStream,
gl: Option<&Arc<glow::Context>>, gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<Self, String> { ) -> Result<Self, String> {
let prefs = engine.playback_prefs(); let prefs = engine.playback_prefs();
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?; let (player, video) = build_player(engine, &target, &prefs, gl)?;
let (commands, orders) = channel::<Command>(); let (commands, orders) = channel::<Command>();
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
@@ -234,6 +259,8 @@ impl Playback {
paused: false, paused: false,
volume: prefs.volume, volume: prefs.volume,
muted: prefs.muted, muted: prefs.muted,
subtitle_position: 100.0,
speed: prefs.speed,
tracks: Vec::new(), tracks: Vec::new(),
chapters: Vec::new(), chapters: Vec::new(),
roles: Vec::new(), roles: Vec::new(),
@@ -242,10 +269,30 @@ impl Playback {
duration: None, duration: None,
loaded: false, loaded: false,
seeking: false, seeking: false,
buffered: None,
buffering: false,
target, target,
}) })
} }
/// Keep subtitles clear of `covered` points at the bottom of a picture
/// `height` points tall — the controls, while they are up.
///
/// Without it the line being read sits under the seek bar for as long as
/// the mouse is moving, which is exactly when someone is looking at the
/// screen.
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
let position = if covered > 0.0 && height > 0.0 {
(100.0 * (1.0 - f64::from(covered / height))).round()
} else {
100.0
};
if position != self.subtitle_position {
self.subtitle_position = position;
self.send(Command::SetSubtitlePosition(position));
}
}
/// Whether the picture is drawn in this window rather than mpv's own. /// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool { pub fn is_embedded(&self) -> bool {
self.video.is_some() self.video.is_some()
@@ -329,6 +376,8 @@ impl Playback {
PlayerEvent::Paused(paused) => self.paused = *paused, PlayerEvent::Paused(paused) => self.paused = *paused,
PlayerEvent::Seek => self.seeking = true, PlayerEvent::Seek => self.seeking = true,
PlayerEvent::PlaybackRestart => self.seeking = false, PlayerEvent::PlaybackRestart => self.seeking = false,
PlayerEvent::Buffered(end) => self.buffered = Some(*end),
PlayerEvent::Buffering(held) => self.buffering = *held,
_ => {} _ => {}
} }
} }
@@ -365,7 +414,6 @@ fn build_player(
target: &PlaybackTarget, target: &PlaybackTarget,
prefs: &PlaybackPrefs, prefs: &PlaybackPrefs,
gl: Option<&Arc<glow::Context>>, gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<(Arc<Player>, Option<VideoSurface>), String> { ) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
let title = format!("proton-stream — {}", target.name); let title = format!("proton-stream — {}", target.name);
@@ -377,6 +425,7 @@ fn build_player(
// controller would be a second set of controls over the same file. // controller would be a second set of controls over the same file.
on_screen_controller: false, on_screen_controller: false,
default_keybindings: false, default_keybindings: false,
hardware_decoding: engine.desktop_prefs().hardware_decoding,
..from_prefs(prefs, target.track_prefs.as_deref()) ..from_prefs(prefs, target.track_prefs.as_deref())
}; };
@@ -386,8 +435,12 @@ fn build_player(
// SAFETY: called from `App::ui`, where eframe has made this // SAFETY: called from `App::ui`, where eframe has made this
// context current, and the surface is dropped from the same // context current, and the surface is dropped from the same
// place — see `video.rs`. // place — see `video.rs`.
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) } match unsafe {
{ VideoSurface::new(Arc::clone(&player), Arc::clone(gl), {
let engine = engine.clone();
move || engine.wake()
})
} {
Ok(video) => return Ok((player, Some(video))), Ok(video) => return Ok((player, Some(video))),
Err(error) => { Err(error) => {
tracing::warn!("embed the video: {error}; falling back to a window"); tracing::warn!("embed the video: {error}; falling back to a window");
@@ -406,6 +459,7 @@ fn build_player(
let config = PlayerConfig { let config = PlayerConfig {
window_title: title, window_title: title,
hardware_decoding: engine.desktop_prefs().hardware_decoding,
..from_prefs(prefs, target.track_prefs.as_deref()) ..from_prefs(prefs, target.track_prefs.as_deref())
}; };
let player = Player::new(engine.runtime().clone(), config) let player = Player::new(engine.runtime().clone(), config)
@@ -428,6 +482,9 @@ fn from_prefs(
.and_then(|p| p.subtitle_language.clone()) .and_then(|p| p.subtitle_language.clone())
.or_else(|| prefs.subtitle_language.clone()), .or_else(|| prefs.subtitle_language.clone()),
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles), subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
// Carried from file to file, like the volume: somebody who watches at
// 1.25 watches the next episode at 1.25 too.
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
..PlayerConfig::default() ..PlayerConfig::default()
} }
} }
@@ -487,8 +544,12 @@ fn run(
PlayerEvent::FileLoaded => { PlayerEvent::FileLoaded => {
duration = player.duration(); duration = player.duration();
// The first moment there is a track list to read: mpv has // The first moment there is a track list to read: mpv has
// demuxed the file and made its own default selection, which // demuxed the file and made its own default selection. Put the
// is what the menus have to open showing. // title's own choice over it before the menus are told, so they
// open showing what is actually playing.
if let Some(show) = target.track_prefs.as_deref() {
restore_tracks(&player, show);
}
emit_tracks(&engine, id, &player); emit_tracks(&engine, id, &player);
// Only now is there a timeline to seek within. // Only now is there a timeline to seek within.
if !resumed && let Some(at) = target.resume_at { if !resumed && let Some(at) = target.resume_at {
@@ -565,6 +626,9 @@ fn apply_command(
Command::SeekTo(seconds) => player.seek_to(seconds), Command::SeekTo(seconds) => player.seek_to(seconds),
Command::SetVolume(volume) => player.set_volume(volume), Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted), Command::SetMuted(muted) => player.set_muted(muted),
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
Command::SetSpeed(speed) => player.set_speed(speed),
Command::Screenshot(path) => player.screenshot(&path),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track), Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
Command::Stop => { Command::Stop => {
let _ = player.quit(); let _ = player.quit();
@@ -611,6 +675,44 @@ fn select_track(
Ok(()) Ok(())
} }
/// Put back the tracks this title was last watched with.
///
/// `alang`/`slang` already steered mpv's pick by language. This is for what a
/// language cannot say — which of two English subtitle tracks, or an audio track
/// with a name and no tag — so only a remembered *title* acts here; a title with
/// nothing but a language keeps mpv's selection, which honours it already.
fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
let tracks = player.tracks();
let wanted = [
(
TrackKind::Audio,
show.audio_language.as_deref(),
show.audio_title.as_deref(),
true,
),
(
TrackKind::Subtitle,
show.subtitle_language.as_deref(),
show.subtitle_title.as_deref(),
show.subtitles,
),
];
for (kind, language, title, enabled) in wanted {
if !enabled || title.is_none() {
continue;
}
let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else {
continue;
};
let playing = tracks
.iter()
.any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected);
if !playing && let Err(error) = player.select_track(kind, Some(track)) {
tracing::warn!("restore {} track {track}: {error}", kind.label());
}
}
}
/// Tell the UI what the file contains and what of it is playing. /// Tell the UI what the file contains and what of it is playing.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) { fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player { engine.emit(Event::Player {
+457 -369
View File
@@ -23,11 +23,11 @@ use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use egui::{ use egui::{
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme, Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
ThemePreference, ViewportCommand, Visuals, ThemePreference, ViewportCommand, Visuals,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use pstr_core::appearance::{Accent, Appearance, Flavor}; use pstr_core::appearance::Appearance;
/// Card geometry. The grid is built from these, so a change here moves /// Card geometry. The grid is built from these, so a change here moves
/// everything together. /// everything together.
@@ -36,26 +36,219 @@ pub const CARD_WIDTH: f32 = 232.0;
pub const CARD_ASPECT: f32 = 9.0 / 16.0; pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0; pub const CARD_GAP: f32 = 14.0;
/// Install a real CJK fallback before the default egui fonts. egui ships a /// The type ramp, named once.
/// compact Latin font, but it intentionally does not bundle the multi-megabyte ///
/// CJK families. Native desktops already provide one, so use it when present. /// Every size in the app comes from here. They used to be literals at the call
pub fn install_font_fallbacks(ctx: &egui::Context) { /// site — `.size(17.0)` in one place and `.size(18.0)` in another for the same
/// kind of heading — which is not a scale, it is forty independent decisions
/// that happen to be close together. Naming them makes the ramp a thing that
/// can be changed, and gives the Android client something to match: a shared
/// palette settles colour, and this settles the other half.
///
/// Sizes rather than full `FontId`s because everything here is proportional;
/// the one monospace style in the app is egui's own, set in [`apply`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// The name of the thing you are looking at, once per page.
Display,
/// An empty state, or the heading of a page that has no display line.
Title,
/// The brand in the nav bar, and a dialog's heading.
Heading,
/// A run of content under a rule.
Section,
/// A row that groups the rows under it — a season header.
Subhead,
/// Everything that is prose.
Body,
/// The text on a small control.
Label,
/// Context rather than content: counts, sizes, errors under a field.
Caption,
/// The smallest thing that stays legible — a badge over artwork.
Micro,
}
impl Role {
pub const fn size(self) -> f32 {
match self {
Self::Display => 26.0,
Self::Title => 20.0,
Self::Heading => 18.0,
Self::Section => 17.0,
Self::Subhead => 15.0,
Self::Body => 14.0,
Self::Label => 13.0,
Self::Caption => 12.0,
Self::Micro => 11.0,
}
}
/// Headings are set in the semibold cut; everything below them in the
/// regular one.
pub fn family(self) -> egui::FontFamily {
match self {
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional,
}
}
/// For the hand-painted parts, which lay text out through a `Painter` and
/// never see a `RichText`.
pub fn font(self) -> egui::FontId {
egui::FontId::new(self.size(), self.family())
}
/// For the parts that go through egui's widgets.
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text)
.size(self.size())
.family(self.family())
}
}
/// The spacing scale. Gaps between things come from here rather than being
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
/// and never 14 in one place and 15 in the next.
pub mod space {
/// Between lines that belong to one thing: a title and its caption.
pub const XXS: f32 = 2.0;
pub const XS: f32 = 4.0;
/// Between controls in one cluster.
pub const S: f32 = 6.0;
pub const M: f32 = 8.0;
/// Between clusters on one row, and between rows of a form.
pub const L: f32 = 12.0;
/// Between the groups on a page.
pub const XL: f32 = 16.0;
/// Between sections.
pub const XXL: f32 = 24.0;
}
/// Corner radii, as `egui::CornerRadius::same` takes them.
pub mod radius {
/// A progress bar, or a strip along an edge.
pub const BAR: u8 = 3;
/// A badge over artwork. Android's `Shape.kt` has the same number.
pub const BADGE: u8 = 4;
/// A small control inside something else: a menu entry, a chip.
pub const SM: u8 = 6;
/// Everything else either client paints: cards, buttons, the tab pill.
pub const MD: u8 = 8;
/// Something that floats: a row card, a dialog, a toast, the hero.
pub const LG: u8 = 12;
}
/// How long things take to change, in seconds, for `Context::animate_*`.
pub mod motion {
/// A press, which has to answer before the finger is off the button.
pub const PRESS: f32 = 0.05;
/// A hover arriving and leaving.
pub const HOVER: f32 = 0.12;
/// A control changing state: a switch flipping, the tab pill travelling,
/// one page giving way to the next.
pub const STATE: f32 = 0.15;
/// Chrome fading in and out: the player's controls, a toast.
pub const FADE: f32 = 0.18;
/// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24;
/// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 {
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
}
}
/// The UI typeface, bundled rather than taken from the system so a title
/// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files.
///
/// Both files have their private use area mappings removed, because the
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
/// own alternate glyphs, and as the first font in the family it won: the
/// refresh arrow drew as an accented letter. The glyphs themselves are still
/// in the files; only the character map entries are gone. Strip them again
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
/// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
/// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own.
pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into())
}
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
/// symbols Inter does not carry. `cjk` goes last, when there is one.
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default();
fonts
.font_data
.insert("inter".into(), Arc::new(egui::FontData::from_static(INTER)));
fonts.font_data.insert(
"inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
);
fonts
.families
.entry(Proportional)
.or_default()
.insert(0, "inter".into());
// Right behind Inter. The icons are in the private use area, which the
// bundled Inter no longer maps, so neither font shadows the other.
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
if let Some(cjk) = cjk {
let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(cjk));
for family in [Proportional, Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
}
let fallbacks = fonts.families[&Proportional]
.iter()
.filter(|name| *name != "inter")
.cloned();
let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks)
.collect();
fonts.families.insert(semibold(), semibold_chain);
fonts
}
/// Install the bundled fonts now, and a CJK fallback when the system has one.
///
/// The bundled ones are compiled in and cost nothing to hand over. The CJK
/// fallback is on a thread of its own: `fc-match` and reading a font file of
/// tens of megabytes held the first frame back by a visible moment, and every
/// name that needs the fallback is still loading from the catalog by then
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
/// and native desktops already provide one.
pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(None));
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("fonts".into())
.spawn(move || install_cjk_fallback(&ctx));
if let Err(error) = spawned {
tracing::debug!("font fallback thread: {error}");
}
}
fn install_cjk_fallback(ctx: &egui::Context) {
let Some((path, index)) = system_cjk_font() else { let Some((path, index)) = system_cjk_font() else {
return; return;
}; };
let Ok(bytes) = std::fs::read(path) else { let Ok(bytes) = std::fs::read(path) else {
return; return;
}; };
let mut fonts = egui::FontDefinitions::default();
let mut data = egui::FontData::from_owned(bytes); let mut data = egui::FontData::from_owned(bytes);
data.index = index; data.index = index;
let name = "system-cjk-fallback".to_owned(); ctx.set_fonts(font_definitions(Some(data)));
fonts.font_data.insert(name.clone(), Arc::new(data)); ctx.request_repaint();
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
ctx.set_fonts(fonts);
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -91,11 +284,12 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
None None
} }
/// Every colour the app draws with, resolved from a [`Flavor`] and an /// Every colour the app draws with, as egui wants them.
/// [`Accent`].
/// ///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything /// The *choice* of colours is `pstr_core::appearance` — one resolution shared
/// a drawing loop has to think about. /// with the Android client, so a flavour means the same thing on both. This is
/// only that palette converted into `Color32`, which is a type `pstr-core`
/// deliberately knows nothing about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette { pub struct Palette {
/// Behind everything: the page. /// Behind everything: the page.
@@ -118,15 +312,54 @@ pub struct Palette {
/// The accent taken down into the background, for a pressed control and for /// The accent taken down into the background, for a pressed control and for
/// selection behind text. /// selection behind text.
pub accent_dim: Color32, pub accent_dim: Color32,
/// Ink that stays readable on top of the accent. Chosen by the accent's /// Ink that stays readable on top of the accent.
/// luminance, because Catppuccin's dark flavours have *pale* accents and
/// white-on-pastel is not readable at 13 px.
pub on_accent: Color32, pub on_accent: Color32,
pub danger: Color32, pub danger: Color32,
/// Whether this is a light theme. /// Whether this is a light theme.
pub light: bool, pub light: bool,
} }
const fn color(rgb: pstr_core::appearance::Rgb) -> Color32 {
Color32::from_rgb(rgb.r, rgb.g, rgb.b)
}
impl Palette {
/// The default, spelled out so the global has something to hold before
/// [`apply`] first runs — a `Ui` built during startup, or a panic message
/// painted before the engine exists, still gets colours.
const PROTON: Self = Self::of(pstr_core::appearance::Palette::PROTON);
const fn of(palette: pstr_core::appearance::Palette) -> Self {
Self {
background: color(palette.background),
surface: color(palette.surface),
sunken: color(palette.sunken),
card: color(palette.card),
card_hover: color(palette.card_hover),
border: color(palette.border),
text: color(palette.text),
muted: color(palette.muted),
accent: color(palette.accent),
accent_alt: color(palette.accent_alt),
accent_dim: color(palette.accent_dim),
on_accent: color(palette.on_accent),
danger: color(palette.danger),
light: palette.light,
}
}
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
Self::of(pstr_core::appearance::Palette::resolve(appearance))
}
}
/// Blend two colours in linear light, through the shared implementation.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
color(pstr_core::appearance::mix(rgb(from), rgb(to), t))
}
/// Which way a gradient runs. /// Which way a gradient runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction { pub enum Direction {
@@ -170,6 +403,16 @@ pub fn danger() -> Color32 {
palette().danger palette().danger
} }
/// Whether ramps are drawn at all.
///
/// Wider than [`gradients`], which also asks whether the *accent* has two hues
/// to run between. The viewer who turns gradients off is usually on a panel that
/// bands them into stripes, and that applies to every ramp in the app, not only
/// the accent's — so the bar fade and the title backdrop ask this one.
pub fn ramps_on() -> bool {
ACTIVE.read().gradients
}
/// Whether the accent is currently drawn as a gradient. /// Whether the accent is currently drawn as a gradient.
/// ///
/// True only when the viewer has gradients on *and* the accent is a pair of /// True only when the viewer has gradients on *and* the accent is a pair of
@@ -197,6 +440,29 @@ pub fn accent_fill(
veil(painter, rect, corner_radius, lift); veil(painter, rect, corner_radius, lift);
} }
/// The accent as a shape rather than paint, for the one caller that has to
/// draw it *behind* something already laid out: the pill that slides between
/// the tabs in the navigation bar, whose position is only known once every tab
/// has been placed.
pub fn accent_shape(
ctx: &egui::Context,
rect: Rect,
corner_radius: impl Into<CornerRadius>,
) -> egui::Shape {
let corner_radius = corner_radius.into();
let palette = palette();
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
return egui::epaint::RectShape::filled(rect, corner_radius, palette.accent).into();
}
let texture = ramp(
ctx,
palette.accent,
palette.accent_alt,
Direction::Horizontal,
);
textured(rect, corner_radius, &texture)
}
/// The same, for a palette that is not the one on screen: the accent swatches /// The same, for a palette that is not the one on screen: the accent swatches
/// on the settings page, each of which previews the theme clicking it would /// on the settings page, each of which previews the theme clicking it would
/// give. /// give.
@@ -225,6 +491,57 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
painter.add(textured(rect, corner_radius, &texture)); painter.add(textured(rect, corner_radius, &texture));
} }
/// A rounded rectangle running from one colour to another, at `opacity`.
///
/// For colours that are not the palette's — a title's own, on a tile with no
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
pub fn ramp_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: CornerRadius,
(from, to): (Color32, Color32),
direction: Direction,
opacity: f32,
) {
let tint = Color32::WHITE.gamma_multiply(opacity);
if !ramps_on() {
painter.rect_filled(
rect,
corner_radius,
mix(from, to, 0.5).gamma_multiply(opacity),
);
return;
}
let texture = ramp(painter.ctx(), from, to, direction);
painter.add(
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
texture.id(),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
),
);
}
/// A ramp from `color` to nothing, for laying artwork into the page.
///
/// The other ramps here run between two opaque colours; this one runs down its
/// own alpha, so what is under it shows through the far end. That is what makes
/// a backdrop end in the page rather than in a horizontal line across the
/// window.
/// `arriving` is which end of `direction` the colour is solid at: `false` for a
/// scrim that starts opaque and lets the picture out, `true` for one that lets
/// the picture *into* the page — which is the one the title backdrop wants,
/// since the page is below the band.
pub fn fade_shape(
ctx: &egui::Context,
rect: Rect,
color: Color32,
direction: Direction,
arriving: bool,
) -> egui::Shape {
let texture = alpha_ramp(ctx, color, direction, arriving);
textured(rect, CornerRadius::ZERO, &texture)
}
/// The one gradient that is not the accent: the top bar, which runs from the /// The one gradient that is not the accent: the top bar, which runs from the
/// surface colour down into the page so that the join between them is a fade /// surface colour down into the page so that the join between them is a fade
/// rather than an edge. /// rather than an edge.
@@ -249,6 +566,56 @@ pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape {
textured(rect, CornerRadius::ZERO, &texture) textured(rect, CornerRadius::ZERO, &texture)
} }
/// The shadow under a raised tile, at `strength` of its full depth.
///
/// Depth is the one separator this app has been doing without: everything is
/// told apart by fill colour, which works until two things that should be at
/// different heights are the same colour. Kept soft and offset downwards —
/// a shadow you can see the edge of reads as a border.
///
/// Scaled rather than switched on, so it can ride the same hover value as the
/// border and arrive with it.
pub fn tile_shadow(strength: f32) -> egui::epaint::Shadow {
shadow(strength, [0, 6], 16, 90)
}
/// The shadow a bar casts onto the page: shallower than a tile's, because a bar
/// is not floating over the page so much as sitting against it.
///
/// `cast_down` for the bar at the top of the window, off for the transport bar
/// at the bottom — a shadow that falls the same way on both would light the
/// window from two directions at once.
pub fn bar_shadow(cast_down: bool) -> egui::epaint::Shadow {
shadow(1.0, [0, if cast_down { 4 } else { -4 }], 12, 70)
}
fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::Shadow {
let strength = strength.clamp(0.0, 1.0);
egui::epaint::Shadow {
offset,
blur,
spread: 0,
// A light theme cannot carry the same shadow: the same black over a
// near-white page is a smudge, where over a dark one it is barely
// visible. Half strength on light, which is roughly where the two read
// the same.
color: Color32::from_black_alpha(
(f32::from(alpha) * strength * if palette().light { 0.5 } else { 1.0 }) as u8,
),
}
}
/// The hover and press film on its own, for a control whose fill was painted by
/// somebody else — the selected tab, which sits on the pill.
pub fn veil_over(
painter: &egui::Painter,
rect: Rect,
corner_radius: impl Into<CornerRadius>,
lift: f32,
) {
veil(painter, rect, corner_radius.into(), lift);
}
/// A white or black film over `rect`, for hover and press states. A no-op at /// A white or black film over `rect`, for hover and press states. A no-op at
/// zero, which is the case that matters — most controls are at rest. /// zero, which is the case that matters — most controls are at rest.
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) { fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
@@ -320,7 +687,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
visuals.selection.stroke = Stroke::new(1.0, palette.text); visuals.selection.stroke = Stroke::new(1.0, palette.text);
visuals.hyperlink_color = palette.accent; visuals.hyperlink_color = palette.accent;
let radius = CornerRadius::same(8); let radius = CornerRadius::same(radius::MD);
visuals.widgets.noninteractive.bg_fill = palette.surface; visuals.widgets.noninteractive.bg_fill = palette.surface;
visuals.widgets.noninteractive.corner_radius = radius; visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border); visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
@@ -351,13 +718,14 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
style.spacing.interact_size.y = 30.0; style.spacing.interact_size.y = 30.0;
style.spacing.scroll.bar_width = 10.0; style.spacing.scroll.bar_width = 10.0;
use egui::FontFamily::Proportional; // egui's own style names, pointed at the ramp above so that a stock
// widget nobody restyled still lands on it.
use egui::TextStyle::{Body, Button, Heading, Monospace, Small}; use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
style.text_styles = [ style.text_styles = [
(Heading, egui::FontId::new(26.0, Proportional)), (Heading, Role::Display.font()),
(Body, egui::FontId::new(14.0, Proportional)), (Body, Role::Body.font()),
(Button, egui::FontId::new(14.0, Proportional)), (Button, Role::Body.font()),
(Small, egui::FontId::new(11.5, Proportional)), (Small, Role::Caption.font()),
( (
Monospace, Monospace,
egui::FontId::new(12.5, egui::FontFamily::Monospace), egui::FontId::new(12.5, egui::FontFamily::Monospace),
@@ -390,7 +758,10 @@ struct Active {
/// flavour the window is not wearing are eight ramps that belong to no /// flavour the window is not wearing are eight ramps that belong to no
/// palette in particular. Bounded by the number of flavours times the /// palette in particular. Bounded by the number of flavours times the
/// number of accents, and only the ones actually drawn. /// number of accents, and only the ones actually drawn.
ramps: BTreeMap<(u32, u32, bool), TextureHandle>, /// The last field separates a ramp between two colours from one that runs
/// down its own alpha — the two would otherwise collide on
/// `(colour, transparent)`.
ramps: BTreeMap<(u32, u32, bool, u8), TextureHandle>,
} }
static ACTIVE: RwLock<Active> = RwLock::new(Active { static ACTIVE: RwLock<Active> = RwLock::new(Active {
@@ -401,17 +772,54 @@ static ACTIVE: RwLock<Active> = RwLock::new(Active {
/// A ramp between two colours, built once and kept. /// A ramp between two colours, built once and kept.
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle { fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
build(ctx, from, to, direction, 0, mix)
}
/// A ramp from `color` down to nothing.
///
/// The alpha is what is interpolated; the colour stays put. Fading the colour
/// as well would take a dark scrim through grey on its way out, which over
/// artwork reads as fog rather than as the picture ending.
fn alpha_ramp(
ctx: &egui::Context,
color: Color32,
direction: Direction,
arriving: bool,
) -> TextureHandle {
build(
ctx,
color,
Color32::TRANSPARENT,
direction,
1 + u8::from(arriving),
move |from, _, t| from.gamma_multiply(if arriving { t } else { 1.0 - t }),
)
}
/// One texture per ramp, built on demand and kept until the palette changes.
///
/// `kind` says which of the three kinds this is, so that a fade and a colour
/// ramp between the same two ends do not come out sharing one texture.
fn build(
ctx: &egui::Context,
from: Color32,
to: Color32,
direction: Direction,
kind: u8,
stop: impl Fn(Color32, Color32, f32) -> Color32,
) -> TextureHandle {
let key = ( let key = (
u32::from_le_bytes(from.to_array()), u32::from_le_bytes(from.to_array()),
u32::from_le_bytes(to.to_array()), u32::from_le_bytes(to.to_array()),
direction == Direction::Vertical, direction == Direction::Vertical,
kind,
); );
if let Some(texture) = ACTIVE.read().ramps.get(&key) { if let Some(texture) = ACTIVE.read().ramps.get(&key) {
return texture.clone(); return texture.clone();
} }
let pixels: Vec<Color32> = (0..RAMP_STOPS) let pixels: Vec<Color32> = (0..RAMP_STOPS)
.map(|stop| mix(from, to, stop as f32 / (RAMP_STOPS - 1) as f32)) .map(|index| stop(from, to, index as f32 / (RAMP_STOPS - 1) as f32))
.collect(); .collect();
let size = match direction { let size = match direction {
Direction::Horizontal => [RAMP_STOPS, 1], Direction::Horizontal => [RAMP_STOPS, 1],
@@ -437,353 +845,31 @@ fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -
/// light, which is what this does. /// light, which is what this does.
const RAMP_STOPS: usize = 64; const RAMP_STOPS: usize = 64;
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
/// ramp between saturated hues, which is exactly where a gradient is looked at.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let from = Rgba::from(from);
let to = Rgba::from(to);
Color32::from(from * (1.0 - t) + to * t)
}
/// Relative luminance, in linear light, as WCAG defines it.
fn luminance(color: Color32) -> f32 {
let rgba = Rgba::from(color);
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Color32, other: Color32) -> f32 {
let (one, other) = (luminance(one), luminance(other));
(one.max(other) + 0.05) / (one.min(other) + 0.05)
}
/// The least contrast a label gets anywhere along a gradient between two
/// colours. Sampled rather than solved: the worst point is not always an end,
/// because the ink can be brighter than one end and darker than the other.
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
(0..=4)
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
.fold(f32::INFINITY, f32::min)
}
/// The least contrast the app will accept between a label and the fill under
/// it: WCAG AA for large text, which is what wears the accent — button labels,
/// a tab, a play glyph, never body copy.
const MIN_CONTRAST: f32 = 3.0;
/// One flavour's raw colours, named as Catppuccin names them.
///
/// Stored rather than computed: these are somebody else's palette, and the
/// point of using it is to use it exactly.
#[derive(Clone, Copy)]
struct Ramp {
base: Color32,
mantle: Color32,
crust: Color32,
surface0: Color32,
surface1: Color32,
subtext0: Color32,
text: Color32,
pink: Color32,
mauve: Color32,
sky: Color32,
sapphire: Color32,
blue: Color32,
lavender: Color32,
teal: Color32,
green: Color32,
peach: Color32,
yellow: Color32,
red: Color32,
light: bool,
}
const fn rgb(hex: u32) -> Color32 {
Color32::from_rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
/// The palette this app shipped with, extended to the hues an accent can be.
///
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
/// chosen to sit on a near-black base at roughly the saturation that one does,
/// which is a good deal hotter than any Catppuccin flavour.
const PROTON: Ramp = Ramp {
base: rgb(0x0e0e12),
mantle: rgb(0x17171d),
crust: rgb(0x0a0a0d),
surface0: rgb(0x1e1e26),
surface1: rgb(0x2a2a35),
subtext0: rgb(0x8e8e9c),
text: rgb(0xeaeaf0),
pink: rgb(0xff5fbe),
mauve: rgb(0x7d4dff),
sky: rgb(0x4dd2ff),
sapphire: rgb(0x3a8fc4),
blue: rgb(0x3f6fe0),
lavender: rgb(0xa68cff),
teal: rgb(0x3fd6b8),
green: rgb(0x56d364),
peach: rgb(0xff9a4d),
yellow: rgb(0xffd166),
red: rgb(0xe05561),
light: false,
};
const LATTE: Ramp = Ramp {
base: rgb(0xeff1f5),
mantle: rgb(0xe6e9ef),
crust: rgb(0xdce0e8),
surface0: rgb(0xccd0da),
surface1: rgb(0xbcc0cc),
subtext0: rgb(0x6c6f85),
text: rgb(0x4c4f69),
pink: rgb(0xea76cb),
mauve: rgb(0x8839ef),
sky: rgb(0x04a5e5),
sapphire: rgb(0x209fb5),
blue: rgb(0x1e66f5),
lavender: rgb(0x7287fd),
teal: rgb(0x179299),
green: rgb(0x40a02b),
peach: rgb(0xfe640b),
yellow: rgb(0xdf8e1d),
red: rgb(0xd20f39),
light: true,
};
const FRAPPE: Ramp = Ramp {
base: rgb(0x303446),
mantle: rgb(0x292c3c),
crust: rgb(0x232634),
surface0: rgb(0x414559),
surface1: rgb(0x51576d),
subtext0: rgb(0xa5adce),
text: rgb(0xc6d0f5),
pink: rgb(0xf4b8e4),
mauve: rgb(0xca9ee6),
sky: rgb(0x99d1db),
sapphire: rgb(0x85c1dc),
blue: rgb(0x8caaee),
lavender: rgb(0xbabbf1),
teal: rgb(0x81c8be),
green: rgb(0xa6d189),
peach: rgb(0xef9f76),
yellow: rgb(0xe5c890),
red: rgb(0xe78284),
light: false,
};
const MACCHIATO: Ramp = Ramp {
base: rgb(0x24273a),
mantle: rgb(0x1e2030),
crust: rgb(0x181926),
surface0: rgb(0x363a4f),
surface1: rgb(0x494d64),
subtext0: rgb(0xa5adcb),
text: rgb(0xcad3f5),
pink: rgb(0xf5bde6),
mauve: rgb(0xc6a0f6),
sky: rgb(0x91d7e3),
sapphire: rgb(0x7dc4e4),
blue: rgb(0x8aadf4),
lavender: rgb(0xb7bdf8),
teal: rgb(0x8bd5ca),
green: rgb(0xa6da95),
peach: rgb(0xf5a97f),
yellow: rgb(0xeed49f),
red: rgb(0xed8796),
light: false,
};
const MOCHA: Ramp = Ramp {
base: rgb(0x1e1e2e),
mantle: rgb(0x181825),
crust: rgb(0x11111b),
surface0: rgb(0x313244),
surface1: rgb(0x45475a),
subtext0: rgb(0xa6adc8),
text: rgb(0xcdd6f4),
pink: rgb(0xf5c2e7),
mauve: rgb(0xcba6f7),
sky: rgb(0x89dceb),
sapphire: rgb(0x74c7ec),
blue: rgb(0x89b4fa),
lavender: rgb(0xb4befe),
teal: rgb(0x94e2d5),
green: rgb(0xa6e3a1),
peach: rgb(0xfab387),
yellow: rgb(0xf9e2af),
red: rgb(0xf38ba8),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
Flavor::Proton => PROTON,
Flavor::Latte => LATTE,
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
}
}
/// The accent, and the hue a gradient in it runs into.
///
/// The partners are neighbours on the wheel rather than complements: a
/// gradient across half the spectrum passes through a colour that belongs
/// to neither end, and on a seek bar that reads as a bug. The exception is
/// [`Accent::PinkSky`], which is the whole point of that entry.
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
match accent {
Accent::Mauve => (self.mauve, self.blue),
Accent::Pink => (self.pink, self.mauve),
Accent::Sky => (self.sky, self.sapphire),
Accent::PinkSky => (self.pink, self.sky),
Accent::Lavender => (self.lavender, self.blue),
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
}
}
}
impl Palette {
/// The default, spelled out as a constant so the global has something to
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
/// panic message painted before the engine exists, still gets colours.
const PROTON: Self = Self {
background: PROTON.base,
surface: PROTON.mantle,
sunken: PROTON.crust,
card: PROTON.surface0,
card_hover: PROTON.surface1,
border: rgb(0x262630),
text: PROTON.text,
muted: PROTON.subtext0,
accent: PROTON.mauve,
accent_alt: PROTON.blue,
accent_dim: rgb(0x5333ad),
on_accent: PROTON.text,
danger: PROTON.red,
light: false,
};
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
let ramp = Ramp::of(appearance.flavor);
let (accent, partner) = ramp.accents(appearance.accent);
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
// at body-text weight — which makes them mid-luminance, and a
// mid-luminance fill is one that neither black nor white sits on. They
// are taken down in value here, and only here: the hue and the
// saturation are Latte's, so it still looks like Latte, and a label on
// a button is legible.
let deepen = |color: Color32| {
if ramp.light {
mix(color, Color32::BLACK, 0.30)
} else {
color
}
};
let accent = deepen(accent);
let accent_alt = if appearance.gradients {
deepen(partner)
} else {
accent
};
// Ink that survives on the accent. The flavour's own reading first —
// pale text on a dark theme — and the opposite when that does not clear
// the bar, which on Catppuccin's dark flavours is most of the time:
// their accents are *pastel*, and white on #f5c2e7 is not a label.
//
// Latte's darkest colour is its body text, and even that is only #4c4f69,
// so the dark side is taken a third of the way to black.
let ink_dark = if ramp.light {
mix(ramp.text, Color32::BLACK, 0.35)
} else {
ramp.crust
};
let ink_light = if ramp.light { ramp.base } else { ramp.text };
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
ink_light
} else {
ink_dark
};
Self {
background: ramp.base,
surface: ramp.mantle,
sunken: ramp.crust,
card: ramp.surface0,
card_hover: ramp.surface1,
border: ramp.surface1,
text: ramp.text,
muted: ramp.subtext0,
accent,
accent_alt,
// Behind text, so it is the accent taken most of the way back to
// the page: a selection in a saturated hue is a selection nobody
// can read through.
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
on_accent,
danger: ramp.red,
light: ramp.light,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn every_flavour_and_accent_resolves_to_readable_ink() { fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
for flavor in Flavor::ALL { use skrifa::MetadataProvider as _;
for accent in Accent::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent,
gradients: true,
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
ratio >= MIN_CONTRAST,
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
);
}
}
}
#[test] for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
fn only_the_light_flavour_reports_itself_light() { let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
for flavor in Flavor::ALL { let shadowed: Vec<u32> = font
let palette = Palette::resolve(Appearance { .charmap()
flavor, .mappings()
..Appearance::default() .map(|(codepoint, _)| codepoint)
}); .filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
assert_eq!(palette.light, flavor.is_light()); .collect();
// And the page is on the right side of the middle either way. assert!(
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light()); shadowed.is_empty(),
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
shadowed.len(),
shadowed[0]
);
} }
} }
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
});
assert_eq!(flat.accent, flat.accent_alt);
}
#[test] #[test]
fn the_shipped_default_is_the_palette_that_was_here_before() { fn the_shipped_default_is_the_palette_that_was_here_before() {
let resolved = Palette::resolve(Appearance::default()); let resolved = Palette::resolve(Appearance::default());
@@ -795,11 +881,13 @@ mod tests {
#[test] #[test]
fn a_ramp_runs_from_one_colour_to_the_other() { fn a_ramp_runs_from_one_colour_to_the_other() {
// The blend itself is `pstr_core::appearance`'s and tested there; what
// is checked here is that the conversion in and out of `Color32` is
// not lossy at the ends.
let from = Color32::from_rgb(0, 0, 0); let from = Color32::from_rgb(0, 0, 0);
let to = Color32::from_rgb(255, 255, 255); let to = Color32::from_rgb(255, 255, 255);
assert_eq!(mix(from, to, 0.0), from); assert_eq!(mix(from, to, 0.0), from);
assert_eq!(mix(from, to, 1.0), to); assert_eq!(mix(from, to, 1.0), to);
assert!(luminance(mix(from, to, 0.5)) > luminance(from)); assert_ne!(mix(from, to, 0.5), from);
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
} }
} }
+197 -131
View File
@@ -5,70 +5,120 @@ use crate::engine::{DownloadItem, DownloadState};
use crate::{theme, ui}; use crate::{theme, ui};
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) { pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
ui.heading("Downloads");
ui.label(ui::muted(
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
));
ui.add_space(12.0);
if downloads.is_empty() { if downloads.is_empty() {
ui.label("No offline downloads."); ui::empty_state(
ui,
"No downloads",
"Download an episode, a season or a whole show from its page, and it plays \
without a connection.",
);
return; return;
} }
let mut start = 0; egui::ScrollArea::vertical()
while start < downloads.len() { .auto_shrink([false, false])
let title_key = &downloads[start].target.title_key; .show(ui, |ui| {
let title = &downloads[start].target.title_name; ui.set_max_width(960.0);
let end = downloads[start..] summary(ui, downloads);
.iter() ui.add_space(theme::space::L);
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
let group = &downloads[start..end];
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
let total: u64 = group.iter().map(|item| item.total).sum();
let completed = group
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count();
ui.label(egui::RichText::new(title).size(17.0).strong()); let mut start = 0;
let totals_known = group.iter().all(|item| item.total > 0); while start < downloads.len() {
ui.horizontal(|ui| { let title_key = &downloads[start].target.title_key;
if totals_known { let end = downloads[start..]
ui.add( .iter()
egui::ProgressBar::new(fraction(downloaded, total)) .position(|item| item.target.title_key != *title_key)
.desired_width(260.0) .map_or(downloads.len(), |offset| start + offset);
.show_percentage(), group(ui, &downloads[start..end], actions);
); ui.add_space(theme::space::L);
} else { start = end;
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("Calculating size…"));
} }
ui.label(ui::muted(format!(
"{} / {} · {completed}/{} files",
bytes(downloaded),
if !totals_known {
"calculating…".to_owned()
} else {
bytes(total)
},
group.len()
)));
}); });
for item in group { }
row(ui, item, actions);
/// The line under the heading: how much, how fast, how long.
fn summary(ui: &mut egui::Ui, downloads: &[DownloadItem]) {
ui::section(ui, "Downloads");
let running: Vec<_> = downloads
.iter()
.filter(|item| item.state == DownloadState::Running)
.collect();
let rate: f64 = running.iter().map(|item| item.rate).sum();
let left: u64 = downloads
.iter()
.filter(|item| {
matches!(
item.state,
DownloadState::Queued | DownloadState::Running | DownloadState::Paused
)
})
.map(|item| item.total.saturating_sub(item.downloaded))
.sum();
let mut parts = vec![format!(
"{} available offline",
ui::library::plural(
downloads
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count(),
"file"
)
)];
if !running.is_empty() {
parts.push(format!("{}/s", size(rate as u64)));
}
if left > 0 {
parts.push(format!("{} to go", size(left)));
if rate > 0.0 {
parts.push(format!("about {}", ui::format_time(left as f64 / rate)));
} }
ui.add_space(12.0); }
start = end; ui.label(ui::muted(parts.join(" · ")));
ui.label(ui::muted(
"Pausing or cancelling keeps every block already downloaded.",
));
}
/// One title's downloads, under a bar for all of them together.
fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
let title = &group[0].target.title_name;
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
let total: u64 = group.iter().map(|item| item.total).sum();
let completed = group
.iter()
.filter(|item| item.state == DownloadState::Completed)
.count();
let totals_known = group.iter().all(|item| item.total > 0);
ui.horizontal(|ui| {
ui.label(theme::Role::Subhead.rich(title).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(ui::muted(if totals_known {
format!(
"{completed} of {} · {} of {}",
group.len(),
size(downloaded),
size(total)
)
} else {
format!("{completed} of {} · working out the size…", group.len())
}));
});
});
if totals_known && group.len() > 1 {
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
}
ui.add_space(theme::space::S);
for item in group {
row(ui, item, actions);
} }
} }
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) { fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
egui::Frame::new() egui::Frame::new()
.fill(theme::card()) .fill(theme::card())
.corner_radius(egui::CornerRadius::same(6)) .corner_radius(egui::CornerRadius::same(theme::radius::SM))
.inner_margin(egui::Margin::symmetric(10, 8)) .inner_margin(egui::Margin::symmetric(12, 8))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -78,81 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
item.target.subtitle.as_str() item.target.subtitle.as_str()
}; };
ui.add_sized( ui.add_sized(
[180.0, 18.0], [72.0, 18.0],
egui::Label::new(egui::RichText::new(caption).strong()).truncate(), egui::Label::new(egui::RichText::new(caption).monospace()).truncate(),
) )
.on_hover_text(&item.target.name); .on_hover_text(&item.target.name);
let status = match &item.state { let (status, colour) = status(item);
DownloadState::Queued => "Queued".to_owned(),
DownloadState::Running => "Downloading".to_owned(),
DownloadState::Paused => "Paused".to_owned(),
DownloadState::Completed => "Available offline".to_owned(),
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
DownloadState::Failed(error) => format!("Failed: {error}"),
};
ui.add_sized( ui.add_sized(
[240.0, 18.0], [220.0, 18.0],
egui::Label::new(ui::muted(status)).truncate(), egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(),
); );
if item.total > 0 { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add( buttons(ui, item, actions);
egui::ProgressBar::new(item.percent()) if item.total > 0 && item.state != DownloadState::Completed {
.desired_width(140.0) ui.add_space(theme::space::M);
.show_percentage(), ui.label(ui::muted(format!(
); "{} / {}",
ui.label(ui::muted(format!( size(item.downloaded),
"{} / {}", size(item.total)
bytes(item.downloaded), )));
bytes(item.total) let width = (ui.available_width() - 8.0).clamp(40.0, 320.0);
))); ui::progress_bar(ui, item.percent(), width);
} }
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| match item.state {
DownloadState::Queued | DownloadState::Running => {
if ui
.button("Cancel")
.on_hover_text("Stop and keep partial")
.clicked()
{
actions.push(Action::CancelDownload(item.key.clone()));
}
if ui.button("Pause").clicked() {
actions.push(Action::PauseDownload(item.key.clone()));
}
}
DownloadState::Paused => {
if ui
.button("Cancel")
.on_hover_text("Stop and keep partial")
.clicked()
{
actions.push(Action::CancelDownload(item.key.clone()));
}
if ui.button("Resume").clicked() {
actions.push(Action::ResumeDownload(item.key.clone()));
}
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui.button("Delete partial…").clicked() {
actions.push(Action::RemoveDownload(item.key.clone(), true));
}
if ui.button("Resume / Retry").clicked() {
actions.push(Action::ResumeDownload(item.key.clone()));
}
}
DownloadState::Completed => {
if ui.button("Make online-only").clicked() {
actions.push(Action::RemoveDownload(item.key.clone(), false));
}
}
},
);
}); });
}); });
ui.add_space(theme::space::XS);
}
/// What a row says about itself, and in what colour.
fn status(item: &DownloadItem) -> (String, egui::Color32) {
match &item.state {
DownloadState::Queued => ("Waiting".to_owned(), theme::muted()),
DownloadState::Running => {
let mut text = format!("{}/s", size(item.rate as u64));
if let Some(eta) = item.eta() {
text.push_str(&format!(" · {} left", ui::format_time(eta)));
}
(text, theme::text())
}
DownloadState::Paused => ("Paused".to_owned(), theme::muted()),
DownloadState::Completed => ("Available offline".to_owned(), theme::muted()),
DownloadState::Cancelled => ("Stopped · progress kept".to_owned(), theme::muted()),
DownloadState::Failed(error) => (format!("Failed: {error}"), theme::danger()),
}
}
fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
let key = || item.key.clone();
match item.state {
DownloadState::Queued | DownloadState::Running => {
if ui
.button("Cancel")
.on_hover_text("Stop, and keep what has downloaded")
.clicked()
{
actions.push(Action::CancelDownload(key()));
}
if ui.button("Pause").clicked() {
actions.push(Action::PauseDownload(key()));
}
}
DownloadState::Paused => {
if ui
.button("Cancel")
.on_hover_text("Stop, and keep what has downloaded")
.clicked()
{
actions.push(Action::CancelDownload(key()));
}
if ui.button("Resume").clicked() {
actions.push(Action::ResumeDownload(key()));
}
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui
.button("Delete")
.on_hover_text("Discard what has downloaded so far")
.clicked()
{
actions.push(Action::RemoveDownload(key(), true));
}
if ui
.button(if matches!(item.state, DownloadState::Failed(_)) {
"Retry"
} else {
"Resume"
})
.clicked()
{
actions.push(Action::ResumeDownload(key()));
}
}
DownloadState::Completed => {
if ui
.button("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(key(), false));
}
}
}
} }
fn fraction(downloaded: u64, total: u64) -> f32 { fn fraction(downloaded: u64, total: u64) -> f32 {
@@ -163,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 {
} }
} }
fn bytes(value: u64) -> String { fn size(bytes: u64) -> String {
const KIB: f64 = 1024.0; ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX))
const MIB: f64 = 1024.0 * KIB;
const GIB: f64 = 1024.0 * MIB;
let value = value as f64;
if value >= GIB {
format!("{:.1} GiB", value / GIB)
} else if value >= MIB {
format!("{:.1} MiB", value / MIB)
} else if value >= KIB {
format!("{:.1} KiB", value / KIB)
} else {
format!("{value:.0} B")
}
} }
+238
View File
@@ -0,0 +1,238 @@
//! Everything that has been played, newest first, under a heading per day.
//!
//! Continue watching answers "what was I in the middle of"; this answers
//! "what was that episode on Tuesday". It is read straight off the watch
//! state, so there is nothing to keep in step: an episode is here because it
//! has a position or is marked watched, and leaves when it has neither.
use chrono::{DateTime, Local, NaiveDate, TimeZone};
use pstr_core::library::{Episode, Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::theme;
use crate::ui::{self, Art};
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
/// How far back the page goes. Further than anyone scrolls; the cap is there
/// so a library with years of history does not lay out every row of it.
const MOST: usize = 400;
/// Where one entry lives in [`Library::titles`]: title, season, episode.
pub type Entry = (usize, usize, usize);
/// The entries of `library.history()`, as positions, so they can be kept
/// between frames and only rebuilt when the library changes.
pub fn entries(library: &Library) -> Vec<Entry> {
let mut position = std::collections::HashMap::new();
for (t, title) in library.titles.iter().enumerate() {
for (s, season) in title.seasons.iter().enumerate() {
for (e, episode) in season.episodes.iter().enumerate() {
position.insert(std::ptr::from_ref(episode), (t, s, e));
}
}
}
library
.history()
.into_iter()
.filter_map(|(_, episode)| position.get(&std::ptr::from_ref(episode)).copied())
.collect()
}
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
entries: &[Entry],
actions: &mut Vec<Action>,
) {
if entries.is_empty() {
ui::empty_state(
ui,
"Nothing watched yet",
"Episodes and films you play show up here, newest first, so the one from \
last week is easy to find again.",
);
return;
}
let today = Local::now().date_naive();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_max_width(960.0);
let mut day = None;
for &(t, s, e) in entries.iter().take(MOST) {
let Some(title) = library.titles.get(t) else {
continue;
};
let Some(episode) = title
.seasons
.get(s)
.and_then(|season| season.episodes.get(e))
else {
continue;
};
let Some(at) = Local.timestamp_opt(episode.last_played(), 0).single() else {
continue;
};
if day != Some(at.date_naive()) {
if day.is_some() {
ui.add_space(theme::space::L);
}
day = Some(at.date_naive());
ui::section(ui, &day_label(at.date_naive(), today));
}
row(ui, art, title, episode, at, actions);
}
});
}
fn row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
at: DateTime<Local>,
actions: &mut Vec<Action>,
) {
let row_id = ui.id().with(("history", &episode.node.link_id));
let frame = egui::Frame::new()
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(8));
let mut prepared = frame.begin(ui);
{
let ui = &mut prepared.content_ui;
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
ui::title::still(
ui,
art,
title,
episode,
episode.is_watched(),
STILL,
actions,
);
ui.add_space(theme::space::M);
let remove = 40.0;
let text_width = (ui.available_width() - remove).max(120.0);
ui.vertical(|ui| {
ui.set_width(text_width);
ui.spacing_mut().item_spacing.y = theme::space::XXS;
ui.add_space(theme::space::XS);
let name = ui
.add(
egui::Label::new(theme::Role::Body.rich(&title.name).color(theme::text()))
.sense(egui::Sense::click())
.truncate(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Show this title");
if name.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
// A film's only "episode" is its file, and its filename says
// nothing the title above it does not.
if title.kind != TitleKind::Film {
let episode_name = art
.episode(&title.key, episode)
.and_then(|found| found.name.clone())
.unwrap_or_else(|| episode.detail().to_owned());
let line = match episode.numbering() {
Some(numbering) => format!("{numbering} · {episode_name}"),
None => episode_name,
};
ui.add(egui::Label::new(ui::muted(line)).truncate())
.on_hover_text(&episode.node.name);
}
ui.label(ui::muted(status(episode, at)));
});
let cluster = egui::vec2(ui.available_width(), STILL.y);
let layout = egui::Layout::right_to_left(egui::Align::Center);
ui.allocate_ui_with_layout(cluster, layout, |ui| {
let remove = ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.on_hover_text(if episode.is_watched() {
"Remove from history — marks it unwatched"
} else {
"Remove from history — forgets where you stopped"
});
if remove.clicked() {
actions.push(Action::RemoveFromHistory {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
});
}
});
});
}
let response = prepared.allocate_space(ui);
let lit =
ui.ctx()
.animate_bool_with_time(row_id, response.contains_pointer(), theme::motion::HOVER);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(theme::space::XXS);
}
/// The grey line under the names: how far it got, and when.
fn status(episode: &Episode, at: DateTime<Local>) -> String {
let when = at.format("%H:%M");
if episode.is_watched() {
return format!("Watched · {when}");
}
match (
episode.resume_at(),
episode.watch.and_then(|watch| watch.duration_secs),
) {
(Some(position), Some(duration)) => format!(
"Stopped at {} of {} · {when}",
ui::format_time(position),
ui::format_time(duration)
),
(Some(position), None) => format!("Stopped at {} · {when}", ui::format_time(position)),
_ => format!("Played · {when}"),
}
}
/// The heading over a day's entries: "Today", "Yesterday", a weekday within
/// the last week, and a date before that — with the year only when it is not
/// this one.
fn day_label(day: NaiveDate, today: NaiveDate) -> String {
use chrono::Datelike;
match (today - day).num_days() {
0 => "Today".into(),
1 => "Yesterday".into(),
2..=6 => day.format("%A").to_string(),
_ if day.year() == today.year() => day.format("%-d %B").to_string(),
_ => day.format("%-d %B %Y").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn a_day_is_named_relative_to_today_for_the_last_week_and_dated_before_that() {
let today = date(2026, 10, 2);
assert_eq!(day_label(today, today), "Today");
assert_eq!(day_label(date(2026, 10, 1), today), "Yesterday");
assert_eq!(day_label(date(2026, 9, 28), today), "Monday");
assert_eq!(day_label(date(2026, 9, 25), today), "25 September");
assert_eq!(day_label(date(2025, 12, 24), today), "24 December 2025");
}
}
+437 -39
View File
@@ -2,53 +2,87 @@
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Page}; use crate::app::{Action, Filter, LibraryView, Page, Sort};
use crate::playback::PlaybackTarget;
use crate::theme; use crate::theme;
use crate::ui::Art; use crate::ui::Art;
use crate::ui::{self, Card}; use crate::ui::{self, Card};
pub fn show( /// What the page lists, and what it was asked for.
ui: &mut egui::Ui, pub struct Shelves<'a> {
art: &mut Art<'_>, pub library: &'a Library,
library: &Library, /// The search and the shelf, already worked out — see [`LibraryView`].
search: &str, pub view: &'a LibraryView,
actions: &mut Vec<Action>, /// Whether the catalog has been read yet.
) { pub loaded: bool,
pub search: &'a str,
}
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
let Shelves {
library,
view,
loaded,
search,
} = shelves;
if !loaded {
return placeholder(ui);
}
if library.is_empty() { if library.is_empty() {
return empty_state(ui, actions); return empty_state(ui, actions);
} }
let matches = library.search(search); let pick = |indices: &[usize]| -> Vec<&Title> {
indices
.iter()
.filter_map(|&index| library.titles.get(index))
.collect()
};
let matches = pick(&view.matches);
let searching = !search.trim().is_empty(); let searching = !search.trim().is_empty();
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
if !searching { if !searching {
let resumable = library.continue_watching(); if let Some(title) = featured(library, view, art.metadata, today()) {
hero(ui, art, title, actions);
ui.add_space(theme::space::XL);
}
let resumable = pick(&view.resumable);
if !resumable.is_empty() { if !resumable.is_empty() {
ui::section(ui, "Continue watching"); ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions); continue_row(ui, art, &resumable, actions);
ui.add_space(16.0); ui.add_space(theme::space::XL);
} }
} }
ui::section( ui.horizontal(|ui| {
ui, ui::section(
&if searching { ui,
format!( &if searching {
"{} matching {:?}", format!(
plural(matches.len(), "title"), "{} matching {:?}",
search.trim() plural(matches.len(), "title"),
) search.trim()
} else { )
plural(matches.len(), "title") } else {
}, plural(matches.len(), "title")
); },
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
shelf_controls(ui, view, actions);
});
});
ui.add_space(theme::space::S);
if matches.is_empty() { if matches.is_empty() {
ui.add_space(8.0); ui.add_space(theme::space::M);
ui.label(ui::muted("Nothing here by that name.")); ui.label(ui::muted(if searching {
"Nothing here by that name."
} else {
"Nothing in the library fits that filter."
}));
return; return;
} }
@@ -56,6 +90,224 @@ pub fn show(
}); });
} }
/// Days since the epoch: what the featured title turns over on.
fn today() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() / 86_400)
}
/// The title the banner shows: whatever was watched last, so the top of the
/// page is one click from carrying on. With nothing part-watched, one of the
/// titles a provider has a backdrop for, turning over once a day — the same
/// one every time the page is opened today, rather than a new one per frame.
fn featured<'a>(
library: &'a Library,
view: &LibraryView,
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
day: u64,
) -> Option<&'a Title> {
if let Some(title) = view
.resumable
.first()
.and_then(|&index| library.titles.get(index))
{
return Some(title);
}
let pictured: Vec<&Title> = library
.titles
.iter()
.filter(|title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| found.backdrop_url.is_some())
})
.collect();
if pictured.is_empty() {
return None;
}
pictured
.get((day % pictured.len() as u64) as usize)
.copied()
}
/// The banner across the top of the library: one title, large, with what it
/// is and a way to play it.
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0);
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
);
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.of(title);
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
let painter = ui.painter().with_clip_rect(rect);
painter.rect_filled(rect, radius, theme::card());
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
picture.is_some(),
theme::motion::ARRIVE,
);
if art_in < 1.0 {
ui::placeholder(&painter, rect, "", 1.0 - art_in);
}
if let Some((texture, _)) = &picture {
painter.add(
egui::epaint::RectShape::filled(
rect,
radius,
egui::Color32::WHITE.gamma_multiply(art_in),
)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
}
// The same two coats as the title page's band, for the same reason: a
// still is arbitrary, and the text over it has to read whatever it is.
if theme::ramps_on() {
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25));
let text_side = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()),
);
painter.add(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
theme::Direction::Horizontal,
false,
));
let bottom =
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max);
painter.add(theme::fade_shape(
ui.ctx(),
bottom,
theme::background(),
theme::Direction::Vertical,
true,
));
} else {
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82));
}
let inner = egui::Rect::from_min_max(
rect.min + egui::vec2(32.0, 0.0),
egui::pos2(
rect.left() + (rect.width() * 0.55).max(360.0),
rect.bottom() - 28.0,
),
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|ui| {
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => {
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
}
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
ui.add_space(theme::space::S);
}
if ui.button("More info").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
});
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
overview.to_owned(),
theme::Role::Body.font(),
theme::text(),
ui.available_width(),
);
job.wrap.max_rows = 3;
ui.label(job);
ui.add_space(theme::space::M);
}
let mut facts = vec![subtitle(title)];
if let Some(found) = &found {
if let Some(rating) = found.rating {
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
facts.extend(found.genres.iter().take(3).cloned());
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(theme::space::XXS);
ui.add(
egui::Label::new(
theme::Role::Display
.rich(&title.name)
.strong()
.color(theme::text()),
)
.truncate(),
);
},
);
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
/// The filter pills and the order, at the right of the grid's heading.
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
// Right to left: the order first, so it ends up last.
let sort = egui::ComboBox::from_id_salt("library-sort")
.selected_text(match view.sort {
Sort::Name => "A – Z",
Sort::Recent => "Recently watched",
Sort::Year => "Newest",
})
.width(150.0)
.show_ui(ui, |ui| {
let mut picked = None;
for (sort, label) in [
(Sort::Name, "A – Z"),
(Sort::Recent, "Recently watched"),
(Sort::Year, "Newest"),
] {
if ui.selectable_label(view.sort == sort, label).clicked() {
picked = Some(sort);
}
}
picked
});
if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort));
}
ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented(
ui,
view.filter,
&[
(Filter::All, "All"),
(Filter::Series, "Series"),
(Filter::Films, "Films"),
(Filter::Watching, "Watching"),
(Filter::Unwatched, "Not started"),
],
) {
actions.push(Action::SetShelf(filter, view.sort));
}
}
/// The top shelf: one card per part-watched title, scrolling sideways. /// The top shelf: one card per part-watched title, scrolling sideways.
fn continue_row( fn continue_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
@@ -85,7 +337,7 @@ fn continue_row(
}) })
.unwrap_or_else(|| episode.label()); .unwrap_or_else(|| episode.label());
let clicked = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: art.of(title),
@@ -93,11 +345,14 @@ fn continue_row(
subtitle: remaining, subtitle: remaining,
progress: episode.progress().map(|value| value as f32), progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(), badge: episode.numbering(),
// A shelf that scrolls sideways has no right edge
// to reach, so nothing to flex to.
width: theme::CARD_WIDTH,
}, },
) );
.clicked(); tile_menu(&response, title, true, actions);
if clicked { if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
} }
} }
@@ -107,11 +362,31 @@ fn continue_row(
/// Every title, wrapped to the window. /// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) { fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let columns = ui::columns(ui.available_width()); let grid = ui::columns(ui.available_width());
for row in titles.chunks(columns) { let row_height = ui::card_height(grid.width);
for row in titles.chunks(grid.columns) {
// A row scrolled out of view is only its height. Laying it out anyway
// costs a subtitle and an artwork lookup per card per frame, and while
// a film plays under the transport bar a frame is every frame.
let slot = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), row_height),
);
if !ui.is_rect_visible(slot) {
// The spacing `horizontal` would have added after itself, too, or
// the page grows and shrinks under the scroll bar as rows come and
// go.
ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP);
continue;
}
ui.horizontal(|ui| { ui.horizontal(|ui| {
// The gap the width was divided around, so what is drawn matches
// what was measured. egui's default item spacing is narrower, and
// the difference times the column count is a visible drift towards
// the left edge.
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in row { for title in row {
let clicked = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: art.of(title),
@@ -121,11 +396,12 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
// What a film is says itself in the subtitle below the // What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise. // card; a second `Film` over the poster is noise.
badge: None, badge: None,
width: grid.width,
}, },
) );
.clicked(); tile_menu(&response, title, false, actions);
if clicked { if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
} }
} }
@@ -134,6 +410,80 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
} }
} }
/// What a right-click on a tile offers: the things otherwise one page away.
///
/// `continuing` is the Continue watching shelf, where the tile can also be
/// taken off the shelf.
fn tile_menu(
response: &egui::Response,
title: &Title,
continuing: bool,
actions: &mut Vec<Action>,
) {
response.context_menu(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => format!("Resume {numbering}"),
(Some(_), None) => "Resume".to_owned(),
(None, _) => "Play".to_owned(),
};
if ui.button(label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
}
if ui.button("Open").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
ui.separator();
let all_watched = title.watched_count() == title.episode_count();
if ui
.button(if all_watched {
"Mark unwatched"
} else {
"Mark watched"
})
.clicked()
{
actions.push(Action::SetTitleWatched {
key: title.key.clone(),
watched: !all_watched,
});
}
if continuing
&& let Some(episode) = title.resume()
&& ui
.button("Remove from Continue watching")
.on_hover_text("Forget where you stopped in it")
.clicked()
{
actions.push(Action::ForgetPosition {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
});
}
let count = title.episode_count();
let download = if count == 1 {
"Download".to_owned()
} else {
format!("Download all {count}")
};
if ui.button(download).clicked() {
actions.push(Action::MakeOffline(
title
.episodes()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
ui.separator();
if ui.button("Change match…").clicked() {
actions.push(Action::OpenMatcher(title.key.clone()));
}
});
}
/// The grey line under a card: what it is, how much there is, and how much is /// The grey line under a card: what it is, how much there is, and how much is
/// left. /// left.
/// ///
@@ -159,19 +509,50 @@ fn subtitle(title: &Title) -> String {
} }
} }
/// The page before the catalog has been read: the shape of a grid, with
/// nothing in it yet.
///
/// Not the empty state. The catalog is on disk and is read in a fraction of a
/// second, and telling every launch "Nothing in the library yet" for that
/// fraction is a flash of something false.
fn placeholder(ui: &mut egui::Ui) {
let grid = ui::columns(ui.available_width());
let height = ui::card_height(grid.width);
ui::section(ui, "Library");
for _ in 0..2 {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for _ in 0..grid.columns {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover());
let image = egui::Rect::from_min_size(
rect.min,
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
);
ui.painter().rect_filled(
image,
egui::CornerRadius::same(theme::radius::MD),
theme::card(),
);
}
});
ui.add_space(theme::CARD_GAP);
}
}
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) { fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add_space(120.0); ui.add_space(120.0);
ui.label( ui.label(
egui::RichText::new("Nothing in the library yet") theme::Role::Title
.size(20.0) .rich("Nothing in the library yet")
.strong(), .strong(),
); );
ui.add_space(6.0); ui.add_space(theme::space::S);
ui.label(ui::muted( ui.label(ui::muted(
"Add a Proton Drive share link, and everything playable behind it shows up here.", "Add a Proton Drive share link, and everything playable behind it shows up here.",
)); ));
ui.add_space(18.0); ui.add_space(theme::space::XL);
if ui::accent_button(ui, "Add a share").clicked() { if ui::accent_button(ui, "Add a share").clicked() {
actions.push(Action::Goto(Page::Shares)); actions.push(Action::Goto(Page::Shares));
} }
@@ -216,6 +597,23 @@ mod tests {
); );
} }
#[test]
fn the_banner_features_the_title_watched_last_and_nothing_without_a_picture() {
let mut second = title(TitleKind::Film, None);
second.key = "second".into();
let library = Library {
titles: vec![title(TitleKind::Film, None), second],
};
let metadata = std::collections::HashMap::new();
let mut view = LibraryView::default();
assert!(featured(&library, &view, &metadata, 0).is_none());
view.resumable = vec![1];
assert_eq!(
featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()),
Some("second")
);
}
#[test] #[test]
fn plurals_read_naturally() { fn plurals_read_naturally() {
assert_eq!(plural(1, "title"), "1 title"); assert_eq!(plural(1, "title"), "1 title");
+19 -20
View File
@@ -43,14 +43,14 @@ pub fn show(
egui::Frame::new() egui::Frame::new()
.fill(theme::surface()) .fill(theme::surface())
.inner_margin(egui::Margin::same(18)) .inner_margin(egui::Margin::same(18))
.corner_radius(egui::CornerRadius::same(10)), .corner_radius(egui::CornerRadius::same(theme::radius::LG)),
) )
.show(ctx, |ui| { .show(ctx, |ui| {
ui.set_width(620.0); ui.set_width(620.0);
ui.label( ui.label(
egui::RichText::new(format!("Match “{}”", matcher.title_name)) theme::Role::Heading
.size(18.0) .rich(format!("Match “{}”", matcher.title_name))
.strong() .strong()
.color(theme::text()), .color(theme::text()),
); );
@@ -58,21 +58,21 @@ pub fn show(
"Everything {} answers with, in its own order — nothing is scored here.", "Everything {} answers with, in its own order — nothing is scored here.",
provider.label() provider.label()
))); )));
ui.add_space(10.0); ui.add_space(theme::space::L);
search_row(ui, matcher, actions); search_row(ui, matcher, actions);
ui.add_space(6.0); ui.add_space(theme::space::S);
current_row(ui, matcher, current.as_ref(), actions); current_row(ui, matcher, current.as_ref(), actions);
ui.add_space(10.0); ui.add_space(theme::space::L);
if let Some(error) = &matcher.error { if let Some(error) = &matcher.error {
ui.label(egui::RichText::new(error).size(12.0).color(theme::danger())); ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(6.0); ui.add_space(theme::space::S);
} }
results(ui, matcher, art, actions); results(ui, matcher, art, actions);
ui.add_space(12.0); ui.add_space(theme::space::L);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() { if ui.button("Close").clicked() {
@@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
if row(ui, art, found).clicked() { if row(ui, art, found).clicked() {
actions.push(Action::ChooseMatch(Box::new(found.clone()))); actions.push(Action::ChooseMatch(Box::new(found.clone())));
} }
ui.add_space(4.0); ui.add_space(theme::space::XS);
} }
}); });
} }
@@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response { fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
let response = egui::Frame::new() let response = egui::Frame::new()
.fill(theme::card()) .fill(theme::card())
.corner_radius(egui::CornerRadius::same(6)) .corner_radius(egui::CornerRadius::same(theme::radius::SM))
.inner_margin(egui::Margin::same(8)) .inner_margin(egui::Margin::same(8))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
@@ -204,16 +204,16 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
} else { } else {
ui.painter().rect_filled( ui.painter().rect_filled(
rect, rect,
egui::CornerRadius::same(4), egui::CornerRadius::same(theme::radius::BADGE),
theme::card_hover(), theme::card_hover(),
); );
} }
ui.add_space(10.0); ui.add_space(theme::space::L);
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label( ui.label(
egui::RichText::new(&found.name) theme::Role::Body
.size(14.0) .rich(&found.name)
.strong() .strong()
.color(theme::text()), .color(theme::text()),
); );
@@ -224,7 +224,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
if let Some(overview) = &found.overview { if let Some(overview) = &found.overview {
let mut job = egui::text::LayoutJob::simple( let mut job = egui::text::LayoutJob::simple(
overview.replace('\n', " "), overview.replace('\n', " "),
egui::FontId::proportional(11.5), theme::Role::Caption.font(),
theme::muted(), theme::muted(),
ui.available_width(), ui.available_width(),
); );
@@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
if response.hovered() { if response.hovered() {
ui.painter().rect_stroke( ui.painter().rect_stroke(
response.rect, response.rect,
egui::CornerRadius::same(6), egui::CornerRadius::same(theme::radius::SM),
egui::Stroke::new(1.5, theme::accent()), egui::Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); );
@@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.or_else(|| found.backdrop_url.clone())?; .or_else(|| found.backdrop_url.clone())?;
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id); let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
let engine = art.engine; let engine = art.engine;
let requested = key.clone();
art.posters art.posters
.texture(key, || engine.request_poster(requested, url)) .texture(&key, || engine.request_poster(key.clone(), url))
} }
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out /// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
@@ -280,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
parts.push(ui::library::plural(episodes as usize, "episode")); parts.push(ui::library::plural(episodes as usize, "episode"));
} }
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}")); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
} }
parts.join(" · ") parts.join(" · ")
} }
+494 -88
View File
@@ -4,12 +4,17 @@
//! applies them after the frame is drawn. //! applies them after the frame is drawn.
pub mod downloads; pub mod downloads;
pub mod history;
pub mod library; pub mod library;
pub mod matcher; pub mod matcher;
pub mod player; pub mod player;
pub mod settings;
pub mod shares; pub mod shares;
pub mod shortcuts;
pub mod title; pub mod title;
pub mod toast;
pub mod transport; pub mod transport;
pub mod widgets;
use std::collections::HashMap; use std::collections::HashMap;
@@ -47,6 +52,28 @@ impl Art<'_> {
.get(episode.node.parsed.season, number) .get(episode.node.parsed.season, number)
} }
/// A still for one episode: the provider's, else Proton's own thumbnail of
/// the file. Only asked for by rows that are on screen.
pub fn still(&mut self, title_key: &str, episode: &Episode) -> Option<egui::TextureHandle> {
let url = self
.episode(title_key, episode)
.and_then(|found| found.still_url.clone());
if let Some(url) = url {
let key = format!("still:{}", url);
let engine = self.engine;
if let Some(texture) = self
.posters
.texture(&key, || engine.request_poster(key.clone(), url))
{
return Some(texture);
}
}
let node = &episode.node;
let engine = self.engine;
self.thumbs
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
}
/// The picture for a title, and how to fit it. /// The picture for a title, and how to fit it.
/// ///
/// Provider artwork first, then Proton's still, then nothing. That order is /// Provider artwork first, then Proton's still, then nothing. That order is
@@ -61,12 +88,9 @@ impl Art<'_> {
.and_then(|metadata| metadata.tile_art()) .and_then(|metadata| metadata.tile_art())
{ {
let engine = self.engine; let engine = self.engine;
let key = title.key.clone(); if let Some(texture) = self.posters.texture(&title.key, || {
let url = url.to_string(); engine.request_poster(title.key.clone(), url.to_string())
if let Some(texture) = self }) {
.posters
.texture(title.key.clone(), || engine.request_poster(key, url))
{
return Some((texture, shape)); return Some((texture, shape));
} }
} }
@@ -75,7 +99,7 @@ impl Art<'_> {
let engine = self.engine; let engine = self.engine;
let texture = self let texture = self
.thumbs .thumbs
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?; .texture(&thumbnail_key(node), || engine.request_thumbnail(node))?;
Some((texture, ArtShape::Landscape)) Some((texture, ArtShape::Landscape))
} }
} }
@@ -94,6 +118,12 @@ pub fn format_time(seconds: f64) -> String {
} }
} }
/// `1.25×`, or `1×`.
pub fn format_speed(speed: f64) -> String {
let text = format!("{speed:.2}");
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
}
/// `1.4 GiB`, for what a file costs to watch. /// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String { pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
@@ -112,30 +142,273 @@ pub fn format_size(bytes: i64) -> String {
/// A section title with a rule under it. /// A section title with a rule under it.
pub fn section(ui: &mut egui::Ui, text: &str) { pub fn section(ui: &mut egui::Ui, text: &str) {
ui.add_space(6.0); ui.add_space(theme::space::S);
ui.label( ui.label(
egui::RichText::new(text) theme::Role::Section
.size(17.0) .rich(text)
.strong() .strong()
.color(theme::text()), .color(theme::text()),
); );
ui.add_space(2.0); ui.add_space(theme::space::XXS);
} }
/// Small grey text, for everything that is context rather than content. /// Small grey text, for everything that is context rather than content.
pub fn muted(text: impl Into<String>) -> egui::RichText { pub fn muted(text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(12.0).color(theme::muted()) theme::Role::Caption.rich(text).color(theme::muted())
} }
/// The one button style that means "this is the action". /// The one button style that means "this is the action".
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response { pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, true, Vec2::new(0.0, 0.0)) filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
} }
/// A tab in the top bar. The selected one wears the accent; the others are text /// The search box in the top bar: a magnifier, the text, and a way to clear it.
/// until the pointer is over them. ///
pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response { /// Escape inside it clears it. `focus` puts the caret in it this frame — which
filled(ui, text, selected, Vec2::new(4.0, 0.0)) /// is what `Ctrl+F` and `/` ask for from anywhere in the library.
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
let id = egui::Id::new("library-search");
let width = 240.0;
let height = ui.spacing().interact_size.y;
let (outer, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
let has_focus = ui.memory(|memory| memory.has_focus(id));
let lit = ui
.ctx()
.animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER);
let radius = CornerRadius::same((height / 2.0) as u8);
ui.painter().rect_filled(outer, radius, theme::card());
ui.painter().rect_stroke(
outer,
radius,
Stroke::new(1.0, theme::card_hover().lerp_to_gamma(theme::accent(), lit)),
egui::StrokeKind::Inside,
);
// A magnifier: a ring and a handle, so no font has to have the glyph.
let ink = theme::muted().lerp_to_gamma(theme::text(), lit);
let lens = egui::pos2(outer.left() + 16.0, outer.center().y - 1.0);
ui.painter().circle_stroke(lens, 5.0, Stroke::new(1.5, ink));
ui.painter().line_segment(
[lens + Vec2::new(3.6, 3.6), lens + Vec2::new(7.0, 7.0)],
Stroke::new(1.5, ink),
);
let clear_width = if search.is_empty() { 0.0 } else { 26.0 };
let field_rect = Rect::from_min_max(
egui::pos2(outer.left() + 28.0, outer.top()),
egui::pos2(outer.right() - 8.0 - clear_width, outer.bottom()),
);
let response = ui.put(
field_rect,
egui::TextEdit::singleline(search)
.id(id)
.frame(egui::Frame::NONE)
.hint_text(if has_focus {
"Titles and filenames"
} else {
"Search Ctrl+F"
})
.vertical_align(egui::Align::Center)
.desired_width(field_rect.width()),
);
if focus {
response.request_focus();
}
if response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Escape)) {
search.clear();
response.surrender_focus();
}
if !search.is_empty() {
let clear = Rect::from_center_size(
egui::pos2(outer.right() - 18.0, outer.center().y),
Vec2::splat(18.0),
);
let response = ui.interact(clear, id.with("clear"), Sense::click());
let ink = if response.hovered() {
theme::text()
} else {
theme::muted()
};
let c = clear.center();
let d = 3.5;
ui.painter().line_segment(
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
Stroke::new(1.5, ink),
);
ui.painter().line_segment(
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Clear (Esc)")
.clicked()
{
search.clear();
}
}
}
/// A thin bar in the accent, for how far something has got.
///
/// egui's own progress bar is one flat colour with the percentage written over
/// it; this is the same bar the tiles and the seek bar use, so a download reads
/// as part of the same app.
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
if ui.is_rect_visible(rect) {
let radius = CornerRadius::same(theme::radius::BAR);
ui.painter().rect_filled(rect, radius, theme::card_hover());
let mut done = rect;
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
if done.width() > 0.5 {
theme::accent_fill(ui.painter(), done, radius, 0.0);
}
}
response
}
/// A centred message for a page with nothing on it: what is missing, and what
/// to do about it.
pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
ui.vertical_centered(|ui| {
ui.add_space(96.0);
ui.label(theme::Role::Title.rich(heading).strong());
ui.add_space(theme::space::S);
ui.label(muted(body));
});
}
/// The danger colour as a button: for the one choice in a dialog that cannot be
/// taken back.
pub fn danger_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.add(
egui::Button::new(egui::RichText::new(text).color(theme::on_accent()))
.fill(theme::danger()),
)
}
/// What a viewer answered a [`confirm`] dialog with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Answer {
/// Still open.
Pending,
Confirmed,
/// Cancelled — by the button, Escape, or a click beside the dialog.
Declined,
}
/// A question with two answers, over everything else.
///
/// For the actions that destroy something the viewer cannot get back from
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
let mut answer = Answer::Pending;
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(420.0);
ui.label(
theme::Role::Heading
.rich(heading)
.strong()
.color(theme::text()),
);
ui.add_space(theme::space::S);
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(theme::space::XL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if danger_button(ui, verb).clicked() {
answer = Answer::Confirmed;
}
if ui.button("Cancel").clicked() {
answer = Answer::Declined;
}
});
});
if answer == Answer::Pending && modal.should_close() {
answer = Answer::Declined;
}
answer
}
/// What [`filled`] paints behind its label.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fill {
/// The accent, gradient and all.
Accent,
/// Nothing — something else already painted the accent here, and the label
/// only has to be legible on it.
Ink,
/// Nothing at rest, the hover colour under the pointer.
None,
}
/// A row of tabs with one accent pill that slides between them.
///
/// Drawn as a row rather than a tab at a time because the pill has to be
/// painted *behind* labels whose positions are not known until they have been
/// laid out, and because a pill that moves has to be one pill: a per-tab fill
/// can only ever cross-fade, which reads as two things blinking rather than one
/// thing travelling.
///
/// Returns the index that was clicked, if any.
pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<usize> {
// Reserved now, filled in below: everything drawn after this lands on top
// of it whatever it turns out to be.
let pill = ui.painter().add(egui::Shape::Noop);
let mut clicked = None;
let mut selected = None;
for (index, (text, is_selected)) in items.iter().enumerate() {
let response = tab(ui, *is_selected, text);
if *is_selected {
selected = Some(response.rect);
}
if response.clicked() {
clicked = Some(index);
}
}
if let Some(target) = selected {
// Interpolating the edges rather than the centre and the width keeps
// the pill from overshooting when it moves between tabs of different
// lengths — both edges arrive at the same time.
let ctx = ui.ctx();
let left =
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
let right =
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
let rect = Rect::from_min_max(
egui::pos2(left, target.top()),
egui::pos2(right, target.bottom()),
);
ui.painter().set(
pill,
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
);
}
clicked
}
/// One tab. The selected one is ink on the pill [`tabs`] draws; the others are
/// text until the pointer is over them.
fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
filled(
ui,
text,
if selected { Fill::Ink } else { Fill::None },
Vec2::new(4.0, 0.0),
)
} }
/// A button painted by hand, so that "filled with the accent" can mean a /// A button painted by hand, so that "filled with the accent" can mean a
@@ -144,7 +417,7 @@ pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
/// `egui::Button` takes a single `Color32` and there is no way in to give it /// `egui::Button` takes a single `Color32` and there is no way in to give it
/// anything else, so the fill, the label and the hover states are all drawn /// anything else, so the fill, the label and the hover states are all drawn
/// here. Everything else — sizing, padding, the click — is still egui's. /// here. Everything else — sizing, padding, the click — is still egui's.
fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::Response { fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Response {
let padding = ui.spacing().button_padding + extra; let padding = ui.spacing().button_padding + extra;
let galley = ui.painter().layout_no_wrap( let galley = ui.painter().layout_no_wrap(
text.to_owned(), text.to_owned(),
@@ -161,24 +434,40 @@ fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::R
return response; return response;
} }
let radius = CornerRadius::same(8); let radius = CornerRadius::same(theme::radius::MD);
let lift = if response.is_pointer_button_down_on() { // Eased rather than switched, so the veil arrives over a few frames instead
-0.14 // of appearing whole the moment the pointer crosses the edge. The press is
} else if response.hovered() { // quicker than the hover: a click should feel like it registered, not like
0.10 // it was considered.
} else { let ctx = ui.ctx().clone();
0.0 let hover = ctx.animate_bool_with_time(
}; response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let press = ctx.animate_bool_with_time(
response.id.with("press"),
response.is_pointer_button_down_on(),
theme::motion::PRESS,
);
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
let ink = if accented { let ink = match fill {
theme::accent_fill(ui.painter(), rect, radius, lift); Fill::Accent => {
theme::on_accent() theme::accent_fill(ui.painter(), rect, radius, lift);
} else { theme::on_accent()
if response.hovered() { }
ui.painter().rect_filled(rect, radius, theme::card_hover()); // The pill is already under this one; only the veil is missing.
theme::text() Fill::Ink => {
} else { theme::veil_over(ui.painter(), rect, radius, lift);
theme::muted() theme::on_accent()
}
Fill::None => {
if hover > 0.0 {
ui.painter()
.rect_filled(rect, radius, theme::card_hover().gamma_multiply(hover));
}
theme::muted().lerp_to_gamma(theme::text(), hover)
} }
}; };
@@ -197,6 +486,23 @@ pub struct Card<'a> {
pub progress: Option<f32>, pub progress: Option<f32>,
/// A short label in the corner — `S01E04`. /// A short label in the corner — `S01E04`.
pub badge: Option<String>, pub badge: Option<String>,
/// How wide to draw it.
///
/// A grid flexes this so its rows reach the right edge of the window; a
/// sideways-scrolling shelf has no edge to reach and passes
/// [`theme::CARD_WIDTH`].
pub width: f32,
}
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
/// before the name is laid out — and because a grid whose rows are each a
/// different height by title length reads as broken.
const CARD_TEXT_HEIGHT: f32 = 54.0;
/// How tall [`card`] draws a tile of this width, so a grid can step over rows
/// it does not draw.
pub fn card_height(width: f32) -> f32 {
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT
} }
/// Draw one tile and report whether it was clicked. /// Draw one tile and report whether it was clicked.
@@ -208,31 +514,63 @@ pub struct Card<'a> {
/// the title, usually. The gap it leaves is filled with the card colour, which /// the title, usually. The gap it leaves is filled with the card colour, which
/// reads as deliberate in a way a stretched poster does not. /// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = theme::CARD_WIDTH; let width = card.width;
let image_height = (width * theme::CARD_ASPECT).round(); let image_height = (width * theme::CARD_ASPECT).round();
// Two lines of name plus one of subtitle. Fixed, because the tile is let text_height = CARD_TEXT_HEIGHT;
// allocated before the name is laid out — and because a grid whose rows are let radius = CornerRadius::same(theme::radius::MD);
// each a different height by title length reads as broken.
let text_height = 54.0;
let radius = CornerRadius::same(8);
let (rect, response) = let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click()); ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
// Reached with the arrow keys or Tab: bring it into view, before the
// early return below would skip a tile that is just off screen.
if response.gained_focus() {
response.scroll_to_me(None);
}
if !ui.is_rect_visible(rect) { if !ui.is_rect_visible(rect) {
return response; return response;
} }
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height)); let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
let hovered = response.hovered(); // Hover is a value, not a flag: the border, the lift and the shadow all
// ride it, so they arrive together rather than each snapping on its own.
// egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here.
// Focus lights a tile the way the pointer does, so the keyboard can be
// followed across the grid.
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered() || response.has_focus(),
theme::motion::HOVER,
);
// The picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle.
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
card.art.is_some(),
theme::motion::ARRIVE,
);
let painter = ui.painter(); let painter = ui.painter();
if hover > 0.0 {
painter.add(theme::tile_shadow(hover).as_shape(image_rect, radius));
}
painter.rect_filled(image_rect, radius, theme::card()); painter.rect_filled(image_rect, radius, theme::card());
// Under the picture, not instead of it: it is what shows through while the
// art fades in, and it is the whole tile when there is no art to come.
if art_in < 1.0 {
placeholder(painter, image_rect, card.name, 1.0 - art_in);
}
// White because a textured rect *multiplies* its fill by the texture; the
// alpha on it is what fades the picture in.
let ink = Color32::WHITE.gamma_multiply(art_in);
match &card.art { match &card.art {
Some((texture, ArtShape::Landscape)) => { Some((texture, ArtShape::Landscape)) => {
let size = texture.size_vec2(); let size = texture.size_vec2();
painter.add( painter.add(
egui::epaint::RectShape::filled(image_rect, radius, Color32::WHITE) egui::epaint::RectShape::filled(image_rect, radius, ink)
.with_texture(texture.id(), cover_uv(size, image_rect.size())), .with_texture(texture.id(), cover_uv(size, image_rect.size())),
); );
} }
@@ -241,7 +579,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
egui::epaint::RectShape::filled( egui::epaint::RectShape::filled(
contain_rect(texture.size_vec2(), image_rect), contain_rect(texture.size_vec2(), image_rect),
radius, radius,
Color32::WHITE, ink,
) )
.with_texture( .with_texture(
texture.id(), texture.id(),
@@ -249,31 +587,20 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
), ),
); );
} }
None => { None => {}
painter.text(
image_rect.center(),
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::muted(),
);
}
} }
if let Some(badge) = &card.badge { if let Some(badge) = &card.badge {
let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0); let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0);
let galley = painter.layout_no_wrap( let galley =
badge.clone(), painter.layout_no_wrap(badge.clone(), theme::Role::Micro.font(), Color32::WHITE);
egui::FontId::proportional(11.0),
Color32::WHITE,
);
let background = Rect::from_min_size( let background = Rect::from_min_size(
anchor - Vec2::new(galley.size().x + 10.0, 0.0), anchor - Vec2::new(galley.size().x + 10.0, 0.0),
galley.size() + Vec2::new(10.0, 4.0), galley.size() + Vec2::new(10.0, 4.0),
); );
painter.rect_filled( painter.rect_filled(
background, background,
CornerRadius::same(4), CornerRadius::same(theme::radius::BADGE),
Color32::from_black_alpha(180), Color32::from_black_alpha(180),
); );
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE); painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
@@ -314,11 +641,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
theme::accent_fill(&clipped_painter, played, played_radius, 0.0); theme::accent_fill(&clipped_painter, played, played_radius, 0.0);
} }
if hovered { if hover > 0.0 {
painter.rect_stroke( painter.rect_stroke(
image_rect, image_rect,
radius, radius,
Stroke::new(2.0, theme::accent()), Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
egui::StrokeKind::Inside, egui::StrokeKind::Inside,
); );
} }
@@ -333,12 +660,10 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
// the line below it. // the line below it.
let mut job = egui::text::LayoutJob::simple( let mut job = egui::text::LayoutJob::simple(
card.name.to_string(), card.name.to_string(),
egui::FontId::proportional(14.0), theme::Role::Body.font(),
if hovered { // Towards white as the pointer arrives, so the name lifts with the
Color32::WHITE // border rather than staying put while everything around it moves.
} else { theme::text().lerp_to_gamma(Color32::WHITE, hover),
theme::text()
},
width, width,
); );
job.wrap.max_rows = 2; job.wrap.max_rows = 2;
@@ -350,7 +675,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
text_rect.min + Vec2::new(0.0, name_height + 2.0), text_rect.min + Vec2::new(0.0, name_height + 2.0),
Align2::LEFT_TOP, Align2::LEFT_TOP,
card.subtitle, card.subtitle,
egui::FontId::proportional(11.5), theme::Role::Caption.font(),
theme::muted(), theme::muted(),
); );
@@ -371,7 +696,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect {
} }
/// UV rectangle that crops `image` to fill `target` without distorting it. /// UV rectangle that crops `image` to fill `target` without distorting it.
fn cover_uv(image: Vec2, target: Vec2) -> Rect { pub fn cover_uv(image: Vec2, target: Vec2) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 { if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
} }
@@ -386,20 +711,84 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect {
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height)) Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
} }
/// Up to two letters, for a tile with no picture. /// What a tile with no picture shows: a gradient of its own, and its name.
fn initials(name: &str) -> String { ///
name.split_whitespace() /// Two grey letters on a grey card was the whole tile for any title nothing
.filter_map(|word| word.chars().next()) /// knew a picture for — a wall of them read as a page that had failed to load.
.filter(|character| character.is_alphanumeric()) /// The colours come from the name, so a title wears the same ones every time
.take(2) /// and two titles side by side rarely match.
.flat_map(char::to_uppercase) ///
.collect() /// The hash is FNV-1a: stable across runs and platforms, which the standard
/// library's hasher deliberately is not.
pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opacity: f32) {
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
});
let hue = (hash % 360) as f32 / 360.0;
let light = theme::palette().light;
let (saturation, value) = if light { (0.25, 0.92) } else { (0.45, 0.34) };
let from: Color32 = egui::ecolor::Hsva::new(hue, saturation, value, 1.0).into();
let to: Color32 =
egui::ecolor::Hsva::new((hue + 0.12) % 1.0, saturation, value * 0.65, 1.0).into();
theme::ramp_fill(
painter,
rect,
CornerRadius::same(theme::radius::MD),
(from, to),
theme::Direction::Vertical,
opacity,
);
let ink = if light {
Color32::from_black_alpha(170)
} else {
Color32::from_white_alpha(215)
}
.gamma_multiply(opacity);
let mut job = egui::text::LayoutJob::simple(
name.to_owned(),
theme::Role::Heading.font(),
ink,
rect.width() - 28.0,
);
job.wrap.max_rows = 3;
job.wrap.overflow_character = Some('…');
job.halign = egui::Align::Center;
let galley = painter.layout_job(job);
painter.galley(
egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0),
galley,
ink,
);
} }
/// How many cards fit, and the gap that leaves them evenly spread. /// How a grid divides the width it was given.
pub fn columns(available: f32) -> usize { #[derive(Debug, Clone, Copy, PartialEq)]
(((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() as usize) pub struct Grid {
.max(1) pub columns: usize,
/// What each card should be drawn at, so the row reaches both edges.
pub width: f32,
}
/// Fit as many cards as will go, then widen them to use the rest.
///
/// [`theme::CARD_WIDTH`] is a *minimum*, not the width. Dividing by it and
/// flooring — which is what this used to do — leaves up to one whole card of
/// dead space at the right of every row, and on a wide window that reads as a
/// layout that failed to line up rather than as a margin.
pub fn columns(available: f32) -> Grid {
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor()
as usize)
.max(1);
// The gaps come out of the width before it is split. Floored, because a
// fraction of a point times four columns is enough to push the last card
// onto a row of its own. A window too narrow for even one full card gets a
// card narrower than the minimum, which is better than one clipped by the
// edge.
let width = ((available - theme::CARD_GAP * (columns - 1) as f32) / columns as f32)
.floor()
.max(1.0);
Grid { columns, width }
} }
#[cfg(test)] #[cfg(test)]
@@ -446,15 +835,32 @@ mod tests {
} }
#[test] #[test]
fn initials_take_two_letters() { fn column_count_never_reaches_zero() {
assert_eq!(initials("Cowboy Bebop"), "CB"); assert_eq!(columns(0.0).columns, 1);
assert_eq!(initials("Akira"), "A"); assert!(columns(2000.0).columns > 1);
assert_eq!(initials(""), "");
} }
#[test] #[test]
fn column_count_never_reaches_zero() { fn a_row_of_cards_fills_the_width_it_was_given() {
assert_eq!(columns(0.0), 1); // The defect this replaced: floor(available / CARD_WIDTH) cards at a
assert!(columns(2000.0) > 1); // fixed width leave up to one card's worth of the window empty.
for available in [400.0, 813.0, 1280.0, 1920.5, 3440.0] {
let grid = columns(available);
let used =
grid.width * grid.columns as f32 + theme::CARD_GAP * (grid.columns - 1) as f32;
assert!(used <= available, "{available}: {used} overflows the row");
assert!(
available - used < grid.columns as f32 + 1.0,
"{available}: {} left over",
available - used,
);
}
}
#[test]
fn cards_never_go_below_the_minimum_while_they_still_fit() {
for available in [500.0, 900.0, 2400.0] {
assert!(columns(available).width >= theme::CARD_WIDTH);
}
} }
} }
+516 -104
View File
@@ -66,6 +66,10 @@ const SKIP_PAD_X: f32 = 22.0;
/// on the full wash. See [`scrim_bottom`]. /// on the full wash. See [`scrim_bottom`].
const SCRIM_ALPHA: f32 = 170.0; const SCRIM_ALPHA: f32 = 170.0;
const SCRIM_FADE: f32 = 88.0; const SCRIM_FADE: f32 = 88.0;
/// How wide the episode list is, at most. A narrow window gives it less.
const DRAWER_WIDTH: f32 = 380.0;
/// The stills in the episode list.
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
/// State the page keeps between frames. /// State the page keeps between frames.
/// ///
@@ -109,6 +113,17 @@ pub struct Chrome<'a> {
pub up_next: Option<UpNextCard>, pub up_next: Option<UpNextCard>,
/// Whether stepping to another episode is possible in either direction. /// Whether stepping to another episode is possible in either direction.
pub neighbours: Neighbours, pub neighbours: Neighbours,
/// The title's episodes, when it has more than one — what the list down
/// the right of the picture shows.
pub episodes: Option<Episodes<'a>>,
}
/// The playing title's episodes, for the list beside the picture.
pub struct Episodes<'a> {
pub title: &'a pstr_core::library::Title,
pub art: ui::Art<'a>,
/// Whether the list is out.
pub open: bool,
} }
/// What the end-of-episode card says, when there is one. /// What the end-of-episode card says, when there is one.
@@ -118,11 +133,21 @@ pub struct Chrome<'a> {
pub struct UpNextCard { pub struct UpNextCard {
/// Seconds until the next episode starts. /// Seconds until the next episode starts.
pub seconds: f64, pub seconds: f64,
/// The same, as a fraction of the whole countdown, for the ring.
pub left: f32,
/// Which episode that is. /// Which episode that is.
pub caption: String, pub caption: String,
/// Its still, once there is one.
pub still: Option<egui::TextureHandle>,
} }
impl Overlay { impl Overlay {
/// How much of the bottom of the window the controls take, the last time
/// they were drawn — so things floating over the picture can keep clear.
pub fn covered(&self) -> f32 {
self.chrome_height
}
/// Fold this frame's pointer into the overlay's timer. /// Fold this frame's pointer into the overlay's timer.
pub fn observe(&mut self, ctx: &egui::Context) { pub fn observe(&mut self, ctx: &egui::Context) {
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos())); let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
@@ -177,6 +202,7 @@ pub fn show(
overlay, overlay,
up_next, up_next,
neighbours, neighbours,
mut episodes,
} = chrome_state; } = chrome_state;
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
@@ -200,6 +226,8 @@ pub fn show(
if !picture { if !picture {
waiting(ui, playback, rect); waiting(ui, playback, rect);
} else if playback.buffering || playback.seeking {
stalled(ui, rect);
} }
// Click anywhere on the picture to pause, which is the one control that // Click anywhere on the picture to pause, which is the one control that
@@ -208,10 +236,18 @@ pub fn show(
actions.push(Action::Player(Command::TogglePause)); actions.push(Action::Player(Command::TogglePause));
} }
if response.double_clicked() { if response.double_clicked() {
// The first half of a double click already paused, and a viewer who
// double-clicked asked for fullscreen, not for a pause — so the second
// half takes the pause back. Waiting out the double-click delay before
// pausing would make every single click feel late instead.
actions.push(Action::Player(Command::TogglePause));
actions.push(Action::ToggleFullscreen); actions.push(Action::ToggleFullscreen);
} }
let visible = overlay.visible(&ctx, playback); let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
// The controls stay up while the list is out: it hangs off the button in
// the title strip, and is read at leisure.
let visible = overlay.visible(&ctx, playback) || drawer_open;
// Above the chrome when it is up, at the bottom corner when it is not — // Above the chrome when it is up, at the bottom corner when it is not —
// and clear of it either way, which is what the measured height buys. // and clear of it either way, which is what the measured height buys.
let float_above = if visible { let float_above = if visible {
@@ -226,16 +262,37 @@ pub fn show(
None => skip(ui, playback, rect, float_above, actions), None => skip(ui, playback, rect, float_above, actions),
} }
if visible { // Last frame's height, like everything else that has to clear the controls.
overlay.chrome_height = chrome( playback.lift_subtitles(
ui, if visible { overlay.chrome_height } else { 0.0 },
playback, rect.height(),
neighbours, );
rect,
overlay.chrome_height, // Faded rather than switched, both ways: controls that blink out under a
actions, // reading eye are more distracting than the ones that were there.
); let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE);
if fade > 0.0 {
overlay.chrome_height = ui
.scope(|ui| {
ui.multiply_opacity(fade);
chrome(
ui,
playback,
neighbours,
episodes.as_ref().map(|list| list.open),
rect,
overlay.chrome_height,
actions,
)
})
.inner;
}
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
} else { } else {
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
}
if !visible {
ui.ctx().set_cursor_icon(egui::CursorIcon::None); ui.ctx().set_cursor_icon(egui::CursorIcon::None);
// The controls have to disappear on their own, and nothing else is // The controls have to disappear on their own, and nothing else is
// going to cause a frame while a film plays without the mouse moving. // going to cause a frame while a film plays without the mouse moving.
@@ -287,7 +344,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
rect.center() - Vec2::new(0.0, 18.0), rect.center() - Vec2::new(0.0, 18.0),
Align2::CENTER_CENTER, Align2::CENTER_CENTER,
&playback.target.title_name, &playback.target.title_name,
egui::FontId::proportional(20.0), theme::Role::Title.font(),
theme::text(), theme::text(),
); );
painter.text( painter.text(
@@ -298,11 +355,23 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
} else { } else {
"playing in mpv's own window" "playing in mpv's own window"
}, },
egui::FontId::proportional(13.0), theme::Role::Label.font(),
theme::muted(), theme::muted(),
); );
} }
/// A spinner over a picture held waiting for data. Without it, a frozen frame
/// with the controls hidden looks the same as a hang.
fn stalled(ui: &mut egui::Ui, rect: Rect) {
let disc = Rect::from_center_size(rect.center(), Vec2::splat(64.0));
ui.painter()
.circle_filled(disc.center(), 32.0, Color32::from_black_alpha(140));
ui.put(
Rect::from_center_size(disc.center(), Vec2::splat(36.0)),
egui::Spinner::new().size(36.0).color(Color32::WHITE),
);
}
/// The screen between two episodes: one has ended and the next is opening. /// The screen between two episodes: one has ended and the next is opening.
/// ///
/// It keeps a way out, because opening a file over a public link can take /// It keeps a way out, because opening a file over a public link can take
@@ -313,14 +382,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
rect.center() - Vec2::new(0.0, 16.0), rect.center() - Vec2::new(0.0, 16.0),
Align2::CENTER_CENTER, Align2::CENTER_CENTER,
"Up next", "Up next",
egui::FontId::proportional(14.0), theme::Role::Body.font(),
theme::muted(), theme::muted(),
); );
ui.painter().text( ui.painter().text(
rect.center() + Vec2::new(0.0, 12.0), rect.center() + Vec2::new(0.0, 12.0),
Align2::CENTER_CENTER, Align2::CENTER_CENTER,
opening.unwrap_or("opening…"), opening.unwrap_or("opening…"),
egui::FontId::proportional(20.0), theme::Role::Title.font(),
theme::text(), theme::text(),
); );
@@ -335,7 +404,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
if ui.button("Back").clicked() { if ui.button("Back").clicked() {
actions.push(Action::LeavePlayer); actions.push(Action::LeavePlayer);
} }
ui.add_space(8.0); ui.add_space(theme::space::M);
ui.add(egui::Spinner::new().size(14.0)); ui.add(egui::Spinner::new().size(14.0));
}, },
); );
@@ -361,7 +430,7 @@ fn skip(
// recap" are different lengths, and one constant leaves the shorter of them // recap" are different lengths, and one constant leaves the shorter of them
// adrift in a box of its own whitespace, which is what made this read as a // adrift in a box of its own whitespace, which is what made this read as a
// rectangle with some text in it instead of a button. // rectangle with some text in it instead of a button.
let font = egui::FontId::proportional(14.0); let font = theme::Role::Body.font();
let text_width = ui let text_width = ui
.painter() .painter()
.layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE) .layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE)
@@ -428,60 +497,114 @@ fn up_next_card(
bottom_margin: f32, bottom_margin: f32,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let size = Vec2::new(330.0, 104.0); const STILL: Vec2 = Vec2::new(128.0, 72.0);
let size = Vec2::new(400.0, 104.0);
floating(ui, rect, size, bottom_margin, |ui| { floating(ui, rect, size, bottom_margin, |ui| {
egui::Frame::new() egui::Frame::new()
.fill(Color32::from_black_alpha(215)) .fill(Color32::from_black_alpha(215))
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120))) .stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
.corner_radius(CornerRadius::same(8)) .corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::symmetric(14, 12)) .inner_margin(egui::Margin::same(12))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(size.x - 28.0); ui.set_width(size.x - 24.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( // The still, with the countdown running round it: what is
egui::RichText::new("Up next") // coming and when, read in one glance.
.size(12.0) let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
.strong() let painter = ui.painter();
.color(Color32::from_white_alpha(200)), let radius = CornerRadius::same(theme::radius::SM);
match &card.still {
Some(texture) => {
painter.add(
egui::epaint::RectShape::filled(frame, radius, Color32::WHITE)
.with_texture(
texture.id(),
ui::cover_uv(texture.size_vec2(), frame.size()),
),
);
}
None => {
painter.rect_filled(frame, radius, Color32::from_white_alpha(20));
}
}
painter.rect_filled(frame, radius, Color32::from_black_alpha(90));
countdown_ring(painter, frame.center(), 22.0, card.left);
// Rounded up, so a ring that says "1" is never followed by
// a second of nothing happening.
painter.text(
frame.center(),
Align2::CENTER_CENTER,
card.seconds.ceil() as u32,
theme::Role::Body.font(),
Color32::WHITE,
); );
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if response.on_hover_text("Play now").clicked() {
// Rounded up, so a card that says "1s" is never
// followed by a second of nothing happening.
ui.label(
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
.size(12.0)
.color(theme::accent()),
);
});
});
ui.add(
egui::Label::new(
egui::RichText::new(&card.caption)
.size(14.0)
.strong()
.color(Color32::WHITE),
)
.truncate(),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next)); actions.push(Action::PlayAdjacent(Adjacent::Next));
} }
ui.add_space(6.0);
if ui ui.add_space(theme::space::L);
.button("Watch till the end") ui.vertical(|ui| {
.on_hover_text("Play this one out — nothing will be skipped") ui.label(
.clicked() theme::Role::Caption
{ .rich("Up next")
actions.push(Action::WatchToEnd); .strong()
} .color(Color32::from_white_alpha(200)),
);
ui.add(
egui::Label::new(
theme::Role::Body
.rich(&card.caption)
.strong()
.color(Color32::WHITE),
)
.truncate(),
);
ui.add_space(theme::space::S);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(theme::space::S);
if ui
.button("Watch till the end")
.on_hover_text("Play this one out — nothing will be skipped")
.clicked()
{
actions.push(Action::WatchToEnd);
}
});
});
}); });
}); });
}); });
} }
/// A ring that empties clockwise from twelve o'clock as `left` runs to zero.
fn countdown_ring(painter: &egui::Painter, center: egui::Pos2, radius: f32, left: f32) {
painter.circle_stroke(
center,
radius,
egui::Stroke::new(3.0, Color32::from_white_alpha(60)),
);
let left = left.clamp(0.0, 1.0);
if left <= 0.0 {
return;
}
let steps = (64.0 * left).ceil().max(2.0) as usize;
let sweep = std::f32::consts::TAU * left;
let points: Vec<egui::Pos2> = (0..=steps)
.map(|step| {
let angle = -std::f32::consts::FRAC_PI_2 + sweep * step as f32 / steps as f32;
center + radius * Vec2::new(angle.cos(), angle.sin())
})
.collect();
painter.add(egui::Shape::line(
points,
egui::Stroke::new(3.0, theme::accent()),
));
}
/// Put something in the bottom-right corner, `bottom_margin` above the edge. /// Put something in the bottom-right corner, `bottom_margin` above the edge.
/// ///
/// The inset matches the controls' own padding rather than being its own /// The inset matches the controls' own padding rather than being its own
@@ -520,6 +643,7 @@ fn chrome(
ui: &mut egui::Ui, ui: &mut egui::Ui,
playback: &Playback, playback: &Playback,
neighbours: Neighbours, neighbours: Neighbours,
drawer: Option<bool>,
rect: Rect, rect: Rect,
previous_height: f32, previous_height: f32,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
@@ -533,9 +657,9 @@ fn chrome(
|ui| { |ui| {
if ui if ui
.add( .add(
egui::Button::new(egui::RichText::new("Library").size(13.0)) egui::Button::new(theme::Role::Label.rich("Library"))
.fill(Color32::from_black_alpha(160)) .fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8)) .corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(96.0, 32.0)), .min_size(Vec2::new(96.0, 32.0)),
) )
.on_hover_text("Keep playing, and go back to the library (Esc)") .on_hover_text("Keep playing, and go back to the library (Esc)")
@@ -543,34 +667,72 @@ fn chrome(
{ {
actions.push(Action::LeavePlayer); actions.push(Action::LeavePlayer);
} }
ui.add_space(14.0); ui.add_space(theme::space::L);
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label( ui.label(
egui::RichText::new(&playback.target.title_name) theme::Role::Heading
.size(18.0) .rich(&playback.target.title_name)
.strong() .strong()
.color(Color32::WHITE), .color(Color32::WHITE),
); );
ui.label( ui.label(
egui::RichText::new(playback.target.caption()) theme::Role::Label
.size(13.0) .rich(playback.target.caption())
.color(Color32::from_white_alpha(190)), .color(Color32::from_white_alpha(190)),
); );
}); });
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
if ui if ui
.add( .add(
egui::Button::new(egui::RichText::new("⛶").size(15.0)) egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
.fill(Color32::from_black_alpha(160)) egui_phosphor::regular::CORNERS_IN
.corner_radius(CornerRadius::same(8)) } else {
.min_size(Vec2::new(38.0, 32.0)), egui_phosphor::regular::CORNERS_OUT
}))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
) )
.on_hover_text("Fullscreen (F)") .on_hover_text(if fullscreen {
"Leave fullscreen (F)"
} else {
"Fullscreen (F)"
})
.clicked() .clicked()
{ {
actions.push(Action::ToggleFullscreen); actions.push(Action::ToggleFullscreen);
} }
if let Some(open) = drawer {
ui.add_space(theme::space::S);
let fill = if open {
theme::accent()
} else {
Color32::from_black_alpha(160)
};
let ink = if open {
theme::on_accent()
} else {
Color32::WHITE
};
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::LIST_BULLETS)
.color(ink),
)
.fill(fill)
.corner_radius(CornerRadius::same(theme::radius::MD))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Episodes (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
}
}); });
}, },
); );
@@ -609,29 +771,225 @@ fn chrome(
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
} }
/// The gradient behind the title strip, fading downwards out of the top edge. /// Which file the episode list last scrolled to, in egui's memory, so it
/// /// scrolls to the playing one once when it opens rather than every frame.
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
/// primitive, and at this size the banding is invisible.
fn scrim_top(ui: &egui::Ui, rect: Rect) { /// The title's episodes, down the right of the picture between the title
const BANDS: usize = 10; /// strip and the controls. A season at a time, opened on the playing one.
let painter = ui.painter(); fn episode_drawer(
let band = rect.height() / BANDS as f32; ctx: &egui::Context,
for index in 0..BANDS { list: &mut Episodes<'_>,
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32; playback: &Playback,
let strip = Rect::from_min_size( rect: Rect,
egui::pos2(rect.left(), rect.top() + band * index as f32), chrome_height: f32,
Vec2::new(rect.width(), band + 1.0), actions: &mut Vec<Action>,
); ) {
painter.rect_filled( let width = DRAWER_WIDTH.min(rect.width() * 0.45);
strip, let area = Rect::from_min_max(
CornerRadius::ZERO, egui::pos2(rect.right() - width - 16.0, rect.top() + TITLE_HEIGHT),
// Squared, so the fade is dark where the text is and gone well egui::pos2(rect.right() - 16.0, rect.bottom() - chrome_height - 8.0),
// before the middle of the picture — a linear ramp over a strip );
// this tall greys the whole top third of the frame. if area.height() < 160.0 {
Color32::from_black_alpha((fraction * fraction * 225.0) as u8), return;
);
} }
let title = list.title;
let target = &playback.target;
let is_playing = |episode: &pstr_core::library::Episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
};
let playing_season = title
.seasons
.iter()
.position(|season| season.episodes.iter().any(is_playing))
.unwrap_or(0);
let season_id = egui::Id::new(("episode-drawer-season", &title.key));
let mut season = ctx
.data(|data| data.get_temp::<usize>(season_id))
.unwrap_or(playing_season)
.min(title.seasons.len().saturating_sub(1));
let margin = theme::space::L;
egui::Area::new(egui::Id::new("episode-drawer"))
.order(egui::Order::Foreground)
.fixed_pos(area.min)
.show(ctx, |ui| {
egui::Frame::new()
.fill(theme::surface().gamma_multiply(0.96))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(margin as i8))
.shadow(theme::tile_shadow(1.0))
.show(ui, |ui| {
ui.set_width(area.width() - 2.0 * margin);
ui.set_height(area.height() - 2.0 * margin);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.on_hover_text("Close (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
});
});
if title.seasons.len() > 1 {
ui.add_space(theme::space::XS);
let labels: Vec<(usize, String)> = title
.seasons
.iter()
.enumerate()
.map(|(index, season)| (index, season.label()))
.collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.map(|(index, label)| (*index, label.as_str()))
.collect();
egui::ScrollArea::horizontal()
.id_salt("drawer-seasons")
.show(ui, |ui| {
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
season = picked;
ctx.data_mut(|data| data.insert_temp(season_id, picked));
}
});
}
ui.add_space(theme::space::S);
let Some(shown) = title.seasons.get(season) else {
return;
};
egui::ScrollArea::vertical()
.id_salt("drawer-episodes")
.auto_shrink([false, false])
.show(ui, |ui| {
for episode in &shown.episodes {
let row = drawer_row(
ui,
&mut list.art,
title,
episode,
is_playing(episode),
actions,
);
if is_playing(episode) {
let scrolled = ctx.data(|data| {
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
});
if scrolled.as_deref() != Some(target.link_id.as_str()) {
row.scroll_to_me(Some(egui::Align::Center));
ctx.data_mut(|data| {
data.insert_temp(
egui::Id::new(DRAWER_SCROLLED),
target.link_id.clone(),
)
});
}
}
}
});
});
});
}
/// One episode in the list: its still, which plays it, and what it is.
fn drawer_row(
ui: &mut egui::Ui,
art: &mut ui::Art<'_>,
title: &pstr_core::library::Title,
episode: &pstr_core::library::Episode,
playing: bool,
actions: &mut Vec<Action>,
) -> egui::Response {
let frame = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(6))
.fill(if playing {
theme::accent_dim()
} else {
Color32::TRANSPARENT
});
let response = frame
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
ui::title::still(
ui,
art,
title,
episode,
episode.is_watched(),
DRAWER_STILL,
actions,
);
ui.add_space(theme::space::M);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = theme::space::XXS;
let name = art
.episode(&title.key, episode)
.and_then(|found| found.name.clone())
.unwrap_or_else(|| episode.detail().to_owned());
if let Some(numbering) = episode.numbering() {
ui.label(
egui::RichText::new(numbering)
.monospace()
.color(theme::muted()),
);
}
let mut job = egui::text::LayoutJob::simple(
name,
theme::Role::Label.font(),
if episode.is_watched() && !playing {
theme::muted()
} else {
theme::text()
},
ui.available_width(),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
let state = if playing {
Some("Playing".to_owned())
} else if episode.is_watched() {
Some("Watched".to_owned())
} else {
episode
.resume_at()
.map(|at| format!("Stopped at {}", ui::format_time(at)))
};
if let Some(state) = state {
ui.label(theme::Role::Caption.rich(state).color(if playing {
theme::accent()
} else {
theme::muted()
}));
}
});
});
})
.response;
ui.add_space(theme::space::XXS);
response
}
/// The gradient behind the title strip, fading downwards out of the top edge.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
// Squared, so the fade is dark where the text is and gone well before the
// middle of the picture — a linear ramp over a strip this tall greys the
// whole top third of the frame.
fade(ui.painter(), rect, |t| {
let fraction = 1.0 - t;
fraction * fraction * 225.0
});
} }
/// The wash behind the controls: flat over the whole strip, fading in above it. /// The wash behind the controls: flat over the whole strip, fading in above it.
@@ -642,27 +1000,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) {
/// strip itself is one flat wash, and the ramp happens in the empty picture /// strip itself is one flat wash, and the ramp happens in the empty picture
/// above it, where there is nothing to obscure. /// above it, where there is nothing to obscure.
fn scrim_bottom(ui: &egui::Ui, strip: Rect) { fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
const BANDS: usize = 12;
let painter = ui.painter(); let painter = ui.painter();
painter.rect_filled( painter.rect_filled(
strip, strip,
CornerRadius::ZERO, CornerRadius::ZERO,
Color32::from_black_alpha(SCRIM_ALPHA as u8), Color32::from_black_alpha(SCRIM_ALPHA as u8),
); );
let above = Rect::from_min_max(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE),
egui::pos2(strip.right(), strip.top()),
);
// Squared again: the wash should meet the strip at full strength and be
// gone within a finger's width of picture above it.
fade(painter, above, |t| t * t * SCRIM_ALPHA);
}
let band = SCRIM_FADE / BANDS as f32; /// Black over `rect`, at the alpha `curve` gives for each fraction of the way
for index in 0..BANDS { /// down it.
let fraction = (index + 1) as f32 / BANDS as f32; ///
let rect = Rect::from_min_size( /// One mesh with a colour per row of vertices, which the GPU interpolates
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32), /// between. It replaced a stack of flat bands, and the bands were what showed:
Vec2::new(strip.width(), band + 1.0), /// each overlapped the next by a pixel so no gap could open between them, and
); /// every overlap was a line of double darkness across the picture.
painter.rect_filled( fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
rect, /// Enough rows that the curve between them reads as a curve.
CornerRadius::ZERO, const ROWS: u32 = 16;
// Squared again: the wash should meet the strip at full strength let mut mesh = egui::Mesh::default();
// and be gone within a finger's width of picture above it. for row in 0..=ROWS {
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8), let t = row as f32 / ROWS as f32;
); let y = egui::lerp(rect.top()..=rect.bottom(), t);
let colour = Color32::from_black_alpha(curve(t).clamp(0.0, 255.0) as u8);
mesh.colored_vertex(egui::pos2(rect.left(), y), colour);
mesh.colored_vertex(egui::pos2(rect.right(), y), colour);
if row > 0 {
let base = (row - 1) * 2;
mesh.add_triangle(base, base + 1, base + 2);
mesh.add_triangle(base + 1, base + 3, base + 2);
}
} }
painter.add(egui::Shape::mesh(mesh));
}
/// What a key just did, in a pill near the top of the picture, for a moment.
///
/// Returns whether it is still showing.
pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool {
/// How long it stays, and how much of that is fading out.
const SECONDS: f64 = 0.9;
const FADE: f64 = 0.25;
let age = ctx.input(|input| input.time) - at;
if age >= SECONDS {
return false;
}
let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("osd"),
));
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE);
let size = galley.size() + Vec2::new(36.0, 18.0);
let screen = ctx.content_rect();
let frame = Rect::from_center_size(
egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0),
size,
);
painter.rect_filled(
frame,
CornerRadius::same((size.y / 2.0) as u8),
Color32::from_black_alpha((190.0 * opacity) as u8),
);
painter.galley_with_override_text_color(
frame.min + Vec2::new(18.0, 9.0),
galley,
Color32::WHITE.gamma_multiply(opacity),
);
ctx.request_repaint();
true
} }
+447
View File
@@ -0,0 +1,447 @@
//! Settings: how the app looks, how it plays, and what it asks the internet.
//!
//! Split out of the shares page, where these lived because that was the only
//! page with a form on it. Nobody looking for the theme looks under the page
//! that manages Proton Drive links, and a page that is half link management and
//! half preferences is two pages.
use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
use crate::theme;
use crate::ui;
/// The settings that are a single value rather than a form.
///
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
/// they are read-only, and passing them one by one is what pushed this
/// function past the argument count anybody can read.
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub autoplay: bool,
pub auto_skip: bool,
pub appearance: Appearance,
pub desktop: DesktopPrefs,
/// What the streaming cache holds, once the shares are open.
pub cache_used: Option<u64>,
}
/// The widest the settings column gets. Rows that run the width of a 2K window
/// put a switch a whole screen away from what it switches.
const COLUMN: f32 = 760.0;
pub fn show(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
prefs: Prefs,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
// Centred, so a wide window frames the column rather than leaving it
// against one edge with the rest of the page empty.
let width = ui.available_width().min(COLUMN);
let margin = ((ui.available_width() - width) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(margin);
ui.vertical(|ui| {
ui.set_width(width);
ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, actions);
playback_form(ui, prefs, actions);
storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions);
about(ui);
});
});
});
}
/// The theme: a flavour, an accent, and whether the accent is a gradient.
///
/// Every control here repaints the whole window the moment it is clicked —
/// there is no "apply" — because the preview *is* the app. A swatch row that
/// only changed a small square would be asking someone to imagine the result.
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(theme::space::L);
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
ui.add_space(theme::space::M);
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
ui.horizontal_wrapped(|ui| {
for flavor in Flavor::ALL {
if palette_card(ui, appearance, flavor)
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
{
actions.push(Action::SetAppearance(Appearance {
flavor,
..appearance
}));
}
}
});
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 0.0;
ui::widgets::settings_row(ui, "Accent", None, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
// Right to left, so reversed to read in the order they are listed.
for accent in Accent::ALL.into_iter().rev() {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
}
});
let mut gradients = appearance.gradients;
ui::widgets::settings_row(
ui,
"Gradient accent",
Some(
"Fade the accent into a second colour. Off draws it flat, which suits a panel that bands gradients into stripes.",
),
|ui| {
if ui::widgets::toggle(ui, &mut gradients, "").changed() {
actions.push(Action::SetAppearance(Appearance {
gradients,
..appearance
}));
}
},
);
});
}
/// One palette, as a miniature of the app wearing it: page, a card on it, a
/// line of text and the accent.
fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> egui::Response {
let size = egui::vec2(132.0, 92.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
flavor,
..appearance
});
let selected = appearance.flavor == flavor;
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let radius = egui::CornerRadius::same(theme::radius::MD);
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
painter.rect_filled(preview, radius, palette.background);
let bar = egui::Rect::from_min_size(preview.min, egui::vec2(size.x, 12.0));
painter.rect_filled(
bar,
egui::CornerRadius {
nw: 8,
ne: 8,
sw: 0,
se: 0,
},
palette.surface,
);
let tile = |x: f32| {
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
};
for x in [8.0, 49.0, 90.0] {
painter.rect_filled(
tile(x),
egui::CornerRadius::same(theme::radius::BAR),
palette.card,
);
}
let pill =
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
theme::swatch_fill(
painter,
pill,
egui::CornerRadius::same(theme::radius::BAR),
&palette,
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 60.0),
preview.top() + 50.0,
egui::Stroke::new(2.0, palette.text),
);
painter.hline(
(preview.left() + 8.0)..=(preview.left() + 40.0),
preview.top() + 56.0,
egui::Stroke::new(2.0, palette.muted),
);
let ring = if selected {
egui::Stroke::new(2.0, theme::accent())
} else {
egui::Stroke::new(
1.0,
theme::card_hover().lerp_to_gamma(theme::muted(), hover),
)
};
painter.rect_stroke(preview, radius, ring, egui::StrokeKind::Outside);
painter.text(
egui::pos2(rect.left() + 2.0, preview.bottom() + 8.0),
egui::Align2::LEFT_TOP,
flavor.label(),
theme::Role::Label.font(),
if selected {
theme::text()
} else {
theme::muted()
},
);
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// One accent, drawn in the colours it would actually produce.
///
/// Resolved against the *selected* flavour rather than the active palette, so
/// the row previews what clicking it would give rather than what is on screen.
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
let selected = appearance.accent == accent;
let size = egui::Vec2::splat(26.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
accent,
..appearance
});
let radius = egui::CornerRadius::same(13);
theme::swatch_fill(ui.painter(), rect, radius, &palette);
if selected {
// A ring rather than a tick: the tick would have to be drawn in an
// ink that reads on eight different fills.
ui.painter().circle_stroke(
rect.center(),
rect.width() / 2.0 + 3.0,
egui::Stroke::new(2.0, theme::text()),
);
}
}
response
.on_hover_text(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The playback settings that are not per-file.
///
/// Volume, audio language, subtitle language and speed are all set from the
/// player itself, where a viewer can hear the result.
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Playback",
Some(
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
),
|ui| {
let mut autoplay = prefs.autoplay;
ui::widgets::settings_row(
ui,
"Play the next episode",
Some("When an episode ends, start the next one. Never after a file that failed."),
|ui| {
if ui::widgets::toggle(ui, &mut autoplay, "").changed() {
actions.push(Action::SetAutoplay(autoplay));
}
},
);
let mut auto_skip = prefs.auto_skip;
ui::widgets::settings_row(
ui,
"Skip openings and credits",
Some(
"Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.",
),
|ui| {
if ui::widgets::toggle(ui, &mut auto_skip, "").changed() {
actions.push(Action::SetAutoSkip(auto_skip));
}
},
);
let mut desktop = prefs.desktop;
ui::widgets::settings_row(
ui,
"Hardware decoding",
Some(
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
),
|ui| {
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
actions.push(Action::SetDesktopPrefs(desktop));
}
},
);
},
);
}
/// The streaming cache: how big it may get, and a way to empty it.
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Storage",
Some(
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
),
|ui| {
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
let used = match prefs.cache_used {
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
None => format!("Up to {budget}"),
};
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
let choices: Vec<(u32, String)> = CACHE_CHOICES
.iter()
.map(|&gib| (gib, format!("{gib} GiB")))
.collect();
let labels: Vec<(u32, &str)> = choices
.iter()
.map(|(gib, label)| (*gib, label.as_str()))
.collect();
if let Some(cache_gib) =
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
{
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
cache_gib,
..prefs.desktop
}));
}
});
ui::widgets::settings_row(
ui,
"Clear the streaming cache",
Some("Frees the space now. Whatever plays next is fetched again."),
|ui| {
if ui.button("Clear").clicked() {
actions.push(Action::ClearStreamCache);
}
},
);
},
);
}
/// What this is, and which version.
fn about(ui: &mut egui::Ui) {
ui::widgets::settings_group(ui, "About", None, |ui| {
ui::widgets::settings_row(
ui,
"proton-stream",
Some("Streams Proton Drive share links without an account or a download step."),
|ui| {
ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
},
);
ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| {
ui.label(ui::muted("press ?"));
});
});
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
///
/// The privacy note is not decoration and it is not in a tooltip. Turning this
/// on sends the titles in someone's library to a third party, which is a thing
/// they can only agree to if they are told — so it is stated where the switch
/// is, before the switch, in the same size as everything else.
fn metadata_form(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 6.0;
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(theme::space::L);
let mut changed = false;
let mut enabled = settings.enabled;
if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
settings.enabled = enabled;
changed = true;
}
if settings.enabled {
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.provider.needs_api_key() {
ui.add_space(theme::space::L);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
ui.add_space(theme::space::L);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
}
});
} else {
ui.add_space(theme::space::S);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
ui.add_space(theme::space::L);
});
}
+131 -279
View File
@@ -7,31 +7,17 @@
//! typed. //! typed.
use pstr_core::Share; use pstr_core::Share;
use pstr_core::appearance::{Accent, Appearance, Flavor}; use pstr_core::library::Library;
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::{Action, ShareForm}; use crate::app::{Action, ShareForm};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
/// The settings on this page that are a single value rather than a form.
///
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
/// they are read-only, and passing them one by one is what pushed this
/// function past the argument count anybody can read.
#[derive(Debug, Clone, Copy)]
pub struct Prefs {
pub autoplay: bool,
pub appearance: Appearance,
}
pub fn show( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
shares: &[Share], shares: &[Share],
library: &Library,
form: &mut ShareForm, form: &mut ShareForm,
settings: &mut MetadataConfig,
prefs: Prefs,
api_key: &mut String,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -39,267 +25,41 @@ pub fn show(
.show(ui, |ui| { .show(ui, |ui| {
ui::section(ui, "Shares"); ui::section(ui, "Shares");
if shares.is_empty() { if shares.is_empty() {
ui.label(ui::muted("No shares yet."));
}
for share in shares {
share_row(ui, share, actions);
}
ui.add_space(22.0);
add_form(ui, form, actions);
ui.add_space(28.0);
playback_form(ui, prefs.autoplay, actions);
ui.add_space(28.0);
appearance_form(ui, prefs.appearance, actions);
ui.add_space(28.0);
metadata_form(ui, settings, api_key, actions);
});
}
/// The theme: a flavour, an accent, and whether the accent is a gradient.
///
/// Every control here repaints the whole window the moment it is clicked —
/// there is no "apply" — because the preview *is* the app. A swatch row that
/// only changed a small square would be asking someone to imagine the result.
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::section(ui, "Appearance");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Palette"));
for flavor in Flavor::ALL {
if ui
.radio(appearance.flavor == flavor, flavor.label())
.on_hover_text(flavor.description())
.clicked()
&& appearance.flavor != flavor
{
actions.push(Action::SetAppearance(Appearance {
flavor,
..appearance
}));
}
}
ui.add_space(12.0);
ui.label(ui::muted("Accent"));
ui.horizontal_wrapped(|ui| {
for accent in Accent::ALL {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance {
accent,
..appearance
}));
}
}
});
ui.add_space(12.0);
let mut gradients = appearance.gradients;
if ui
.checkbox(&mut gradients, "Fade the accent into a second colour")
.on_hover_text(
"Off draws it flat — better on a panel that bands a gradient into stripes",
)
.changed()
{
actions.push(Action::SetAppearance(Appearance {
gradients,
..appearance
}));
}
});
}
/// One accent, drawn in the colours it would actually produce.
///
/// Resolved against the *selected* flavour rather than the active palette, so
/// the row previews what clicking it would give rather than what is on screen.
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
let selected = appearance.accent == accent;
let size = egui::Vec2::new(52.0, 26.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance {
accent,
..appearance
});
let radius = egui::CornerRadius::same(6);
theme::swatch_fill(ui.painter(), rect, radius, &palette);
if selected {
// A ring rather than a tick: the tick would have to be drawn in an
// ink that reads on eight different fills.
ui.painter().rect_stroke(
rect.expand(2.0),
egui::CornerRadius::same(8),
egui::Stroke::new(2.0, theme::text()),
egui::StrokeKind::Outside,
);
}
}
response
.on_hover_text(accent.label())
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
///
/// The privacy note is not decoration and it is not in a tooltip. Turning this
/// on sends the titles in someone's library to a third party, which is a thing
/// they can only agree to if they are told — so it is stated where the switch
/// is, before the switch, in the same size as everything else.
/// The handful of playback settings that are not per-file.
///
/// Volume, audio language and subtitle language are all set from the player
/// itself, where a viewer can hear the result — only autoplay has no natural
/// home there, because by the time it matters the episode is over.
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
let mut enabled = autoplay;
if ui
.checkbox(&mut enabled, "Play the next episode automatically")
.on_hover_text("Only when an episode reaches its end, never after a failure")
.changed()
{
actions.push(Action::SetAutoplay(enabled));
}
ui.label(ui::muted(
"Volume, audio language and subtitles are set from the player, and remembered \
from there.",
));
});
}
fn metadata_form(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(10.0);
let mut changed = false;
let mut enabled = settings.enabled;
if ui
.checkbox(&mut enabled, "Look up posters and descriptions")
.changed()
{
settings.enabled = enabled;
changed = true;
}
if settings.enabled {
ui.add_space(10.0);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.provider.needs_api_key() {
ui.add_space(10.0);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
}
});
} else {
ui.add_space(6.0);
ui.label(ui::muted( ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \ "No shares yet. Add one below and its contents become your library.",
already stored.",
)); ));
} }
for share in shares {
if changed { let titles = library
actions.push(Action::SetMetadataConfig(settings.clone())); .titles
.iter()
.filter(|title| title.share_ids.contains(&share.id))
.count();
share_row(ui, share, titles, actions);
} }
ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions);
}); });
} }
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) { fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
egui::Frame::new() egui::Frame::new()
.fill(theme::card()) .fill(theme::card())
.corner_radius(egui::CornerRadius::same(8)) .corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::symmetric(12, 10)) .inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label(egui::RichText::new(&share.name).strong()); ui.label(egui::RichText::new(&share.name).strong());
let detail = if share.has_custom_password { // What is in it, rather than what it is called inside the
format!("{} · custom password", share.id) // app. The id is still a hover away for a bug report.
} else { let mut detail = ui::library::plural(titles, "title");
share.id.clone() if share.has_custom_password {
}; detail.push_str(" · password protected");
ui.label(ui::muted(detail)); }
ui.label(ui::muted(detail)).on_hover_text(&share.id);
}); });
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -316,20 +76,20 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
}); });
}); });
}); });
ui.add_space(6.0); ui.add_space(theme::space::S);
} }
fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>) { fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
ui::section(ui, "Add a share"); ui::section(ui, "Add a share");
ui.label(ui::muted( ui.label(ui::muted(
"Paste the whole link, including everything after the # — that part is the key that \ "Paste the whole link, including everything after the # — that part is the key that \
decrypts it, and it is stored in your system keyring rather than on disk.", decrypts it, and it is stored in your system keyring rather than on disk.",
)); ));
ui.add_space(10.0); ui.add_space(theme::space::L);
egui::Frame::new() egui::Frame::new()
.fill(theme::card()) .fill(theme::card())
.corner_radius(egui::CornerRadius::same(8)) .corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(14)) .inner_margin(egui::Margin::same(14))
.show(ui, |ui| { .show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0)); ui.set_width(ui.available_width().min(640.0));
@@ -340,20 +100,45 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
.hint_text("Anime") .hint_text("Anime")
.desired_width(f32::INFINITY), .desired_width(f32::INFINITY),
); );
ui.add_space(8.0); ui.add_space(theme::space::M);
ui.label(ui::muted("Link")); ui.label(ui::muted("Link"));
ui.add( ui.horizontal(|ui| {
egui::TextEdit::singleline(&mut form.url) let paste = 60.0;
.password(true) let field = egui::Id::new("share-link");
.hint_text("https://drive.proton.me/urls/…#…") ui.add(
.desired_width(f32::INFINITY), egui::TextEdit::singleline(&mut form.url)
); .id(field)
ui.add_space(8.0); .password(true)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(ui.available_width() - paste),
);
// The link is masked, so pasting is the only sensible way in —
// and a button says that more plainly than the field does.
if ui
.add_sized(
[paste - 8.0, ui.spacing().interact_size.y],
egui::Button::new("Paste"),
)
.clicked()
{
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
// The paste lands in whatever has focus, so give it some.
ui.memory_mut(|memory| memory.request_focus(field));
}
});
// Said while typing, not after a refused submit: everything this
// checks is knowable from the text alone.
let problem = link_problem(&form.url, shares);
if let Some(problem) = &problem {
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
}
ui.add_space(theme::space::M);
ui.checkbox(&mut form.has_password, "The link asks for a password"); ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
if form.has_password { if form.has_password {
ui.add_space(6.0); ui.add_space(theme::space::S);
ui.add( ui.add(
egui::TextEdit::singleline(&mut form.password) egui::TextEdit::singleline(&mut form.password)
.password(true) .password(true)
@@ -362,11 +147,17 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
); );
} }
ui.add_space(12.0); ui.add_space(theme::space::L);
let ready = !form.name.trim().is_empty() let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty() && !form.url.trim().is_empty()
&& (!form.has_password || !form.password.is_empty()); && problem.is_none()
&& (!form.has_password || !form.password.is_empty())
&& !form.sending;
if let Some(error) = &form.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl")) .add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
@@ -382,9 +173,70 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
.filter(|password| !password.is_empty()), .filter(|password| !password.is_empty()),
}); });
} }
if !ready { if form.sending {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("adding…"));
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
ui.label(ui::muted("A name and a link are needed.")); ui.label(ui::muted("A name and a link are needed."));
} }
}); });
}); });
} }
/// What is wrong with a link as typed, if anything.
///
/// Nothing for an empty box — that is "not done yet", which the button already
/// says.
fn link_problem(url: &str, shares: &[Share]) -> Option<String> {
let url = url.trim();
if url.is_empty() {
return None;
}
match pstr_core::shares::share_token(url) {
Ok(token) if shares.iter().any(|share| share.token == token) => {
Some("That share is already added.".into())
}
Ok(_) => None,
Err(error) => Some(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn share(token: &str) -> Share {
Share {
id: format!("share-{token}"),
name: "anime".into(),
token: token.into(),
has_custom_password: false,
}
}
#[test]
fn an_empty_link_is_not_yet_a_problem() {
assert_eq!(link_problem(" ", &[]), None);
}
#[test]
fn a_link_without_its_key_is_refused_while_typing() {
assert!(link_problem("https://drive.proton.me/urls/ABC123", &[]).is_some());
assert!(link_problem("https://example.com/", &[]).is_some());
assert_eq!(
link_problem("https://drive.proton.me/urls/ABC123#key", &[]),
None
);
}
#[test]
fn a_share_already_added_is_refused_while_typing() {
assert!(
link_problem(
"https://drive.proton.me/urls/ABC123#key",
&[share("ABC123")]
)
.is_some()
);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! The list of keyboard shortcuts, on `?`.
//!
//! Written out here rather than generated from the handlers, because the
//! handlers are split between the player page and every other page, and what a
//! viewer needs is one list grouped by where they are.
use crate::theme;
use crate::ui;
const GROUPS: &[(&str, &[(&str, &str)])] = &[
(
"Anywhere",
&[
("Ctrl F or /", "Search the library"),
("F5 or Ctrl R", "Crawl every share again"),
(
"Ctrl 1 – 5",
"Library, History, Shares, Downloads, Settings",
),
("Ctrl ,", "Settings"),
("Ctrl V", "Paste a share link to add it"),
("Tab or arrows", "Move between titles and episodes"),
("Enter", "Open or play what is highlighted"),
("Alt ← or mouse back", "Back"),
("?", "This list"),
],
),
(
"Playing",
&[
("Space or K", "Pause and play"),
("← / →", "Back 10 s, forward 30 s"),
("↑ / ↓", "Volume"),
("Ctrl S", "Save the frame to Pictures/proton-stream"),
("M", "Mute"),
("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"),
("E", "The list of episodes"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
],
),
];
/// Draw the list. Returns whether it asked to be closed.
pub fn show(ctx: &egui::Context) -> bool {
let mut close = false;
let modal = egui::Modal::new(egui::Id::new("shortcuts"))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(20))
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
)
.show(ctx, |ui| {
ui.set_width(460.0);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Keyboard shortcuts").strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
close = true;
}
});
});
for (heading, keys) in GROUPS {
ui.add_space(theme::space::L);
ui.label(ui::muted(heading.to_uppercase()));
ui.add_space(theme::space::XS);
egui::Grid::new(*heading)
.num_columns(2)
.spacing([18.0, 8.0])
.show(ui, |ui| {
for (key, what) in *keys {
ui.label(
theme::Role::Label
.rich(*key)
.monospace()
.color(theme::accent()),
);
ui.label(theme::Role::Body.rich(*what));
ui.end_row();
}
});
}
});
close || modal.should_close()
}
+638 -231
View File
@@ -30,61 +30,153 @@ pub fn show(
return; return;
}; };
if ui.button("Library").clicked() { if back_link(ui).clicked() {
actions.push(Action::Goto(Page::Library)); actions.push(Action::Goto(Page::Library));
} }
ui.add_space(10.0); ui.add_space(theme::space::S);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
header(ui, art, title, &offline, actions); header(ui, art, title, &offline, actions);
ui.add_space(18.0); ui.add_space(theme::space::XL);
for (index, season) in title.seasons.iter().enumerate() { // One season at a time, picked from a row of pills, rather than
let open = index == 0 || title.seasons.len() == 1; // every season stacked as a collapsing header: a four-season show
egui::CollapsingHeader::new( // was a page of headers to scroll past to reach the one being
egui::RichText::new(format!( // watched. The one opened first is the one with the next episode.
"{} · {}", let picked_id = ui.id().with(("season", &title.key));
season.label(), let default = title
ui::library::plural(season.episodes.len(), "episode") .next_up()
)) .and_then(|next| {
.size(15.0) title.seasons.iter().position(|season| {
.strong(), season
) .episodes
.id_salt(("season", index)) .iter()
.default_open(open) .any(|episode| episode.node.link_id == next.node.link_id)
.show(ui, |ui| { })
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect(); })
let all_offline = season_keys.iter().all(|key| offline.files.contains(key)); .unwrap_or(0);
if all_offline { let mut picked = ui
if ui.button("Make season online-only").clicked() { .data(|data| data.get_temp::<usize>(picked_id))
for key in season_keys { .unwrap_or(default)
actions.push(Action::RemoveDownload(key, false)); .min(title.seasons.len().saturating_sub(1));
}
} let Some(season) = title.seasons.get(picked) else {
} else if ui return;
.button("Make season offline") };
.on_hover_text("Download every episode in this season") ui.horizontal(|ui| {
.clicked() if title.seasons.len() > 1 {
{ let labels: Vec<String> =
actions.push(Action::MakeOffline( title.seasons.iter().map(|season| season.label()).collect();
season let choices: Vec<(usize, &str)> = labels
.episodes .iter()
.iter() .enumerate()
.map(|episode| PlaybackTarget::new(title, episode)) .map(|(index, label)| (index, label.as_str()))
.collect(), .collect();
)); if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
} picked = index;
for episode in &season.episodes { ui.data_mut(|data| data.insert_temp(picked_id, index));
episode_row(ui, art, title, season, episode, &offline, actions);
} }
ui.add_space(theme::space::L);
}
ui.label(ui::muted(ui::library::plural(
season.episodes.len(),
"episode",
)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
season_download(ui, title, season, &offline, actions);
}); });
ui.add_space(6.0); });
ui.add_space(theme::space::L);
let Some(season) = title.seasons.get(picked) else {
return;
};
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, &offline, actions);
} }
ui.add_space(theme::space::L);
}); });
} }
/// A title's poster, at the poster's own shape, with how far into it the
/// viewer is along its foot.
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
let size = egui::vec2(180.0, 270.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let radius = egui::CornerRadius::same(theme::radius::LG);
ui.painter()
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
ui.painter().add(
egui::epaint::RectShape::filled(rect, radius, egui::Color32::WHITE)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), size)),
);
if let Some(progress) = progress {
let track = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - 4.0),
rect.right_bottom(),
);
let painter = ui.painter().with_clip_rect(track);
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
let mut played = rect;
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
theme::accent_fill(&painter, played, radius, 0.0);
}
}
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
response.rect.x_range(),
response.rect.bottom(),
egui::Stroke::new(1.0, theme::muted()),
);
}
response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Back (Esc, Alt ←)")
}
/// Download, or delete, a whole season.
fn season_download(
ui: &mut egui::Ui,
title: &Title,
season: &Season,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
if all_offline {
if ui
.button("Delete season")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
for key in season_keys {
actions.push(Action::RemoveDownload(key, false));
}
}
} else if ui
.button("Download season")
.on_hover_text("Download every episode in this season")
.clicked()
{
actions.push(Action::MakeOffline(
season
.episodes
.iter()
.map(|episode| PlaybackTarget::new(title, episode))
.collect(),
));
}
}
/// The still, the name and the one button that matters. /// The still, the name and the one button that matters.
fn header( fn header(
ui: &mut egui::Ui, ui: &mut egui::Ui,
@@ -98,22 +190,46 @@ fn header(
let record = art.metadata.get(&title.key); let record = art.metadata.get(&title.key);
let found = record.and_then(|record| record.metadata.clone()); let found = record.and_then(|record| record.metadata.clone());
let by_hand = record.is_some_and(|record| record.manual); let by_hand = record.is_some_and(|record| record.manual);
// Asked for once and used twice: the same picture is the poster beside the
// text and the banner behind all of it.
let picture = art.of(title);
// Reserved before the header is laid out, because the band is as tall as
// whatever the header comes to — a fixed height would clip a long overview
// on one title and leave a gap under a short one on the next.
let backdrop = picture
.as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
ui.add_space(theme::space::S);
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
ui::card( match &picture {
ui, // A poster is drawn as a poster. Fitted into the 16:9 tile the grid
Card { // uses, it was a narrow strip of picture between two bars.
art: art.of(title), Some((texture, pstr_core::metadata::ArtShape::Portrait)) => {
name: &title.name, poster(ui, texture, title.resume().and_then(|e| e.progress()));
subtitle: String::new(), }
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), _ => {
badge: None, ui::card(
}, ui,
); Card {
art: picture,
name: &title.name,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
width: theme::CARD_WIDTH,
},
);
}
}
ui.add_space(18.0); ui.add_space(theme::space::XXL);
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label(egui::RichText::new(&title.name).size(26.0).strong()); // Text in a column a person can read, with the picture to the
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&title.name).strong());
// The provider's name for it, when it is not the one the files use. // The provider's name for it, when it is not the one the files use.
// Worth showing rather than replacing the filename's: a viewer // Worth showing rather than replacing the filename's: a viewer
// should be able to tell what the match actually matched. // should be able to tell what the match actually matched.
@@ -126,16 +242,24 @@ fn header(
if by_hand { " (chosen by hand)" } else { "" } if by_hand { " (chosen by hand)" } else { "" }
))); )));
} }
ui.add_space(4.0); ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref()))); ui.label(ui::muted(meta_line(title, found.as_ref())));
ui.add_space(14.0); ui.add_space(theme::space::L);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if let Some(episode) = title.next_up() { if let Some(episode) = title.next_up() {
let resuming = episode.resume_at().is_some(); let resuming = episode.resume_at().is_some();
// Where it resumes, in the button that resumes it. As a
// label of its own it ended up three controls further
// along, after the download buttons, reading as a fact
// about those.
let at = episode
.resume_at()
.map(|at| format!(" · {}", ui::format_time(at)))
.unwrap_or_default();
let label = match (resuming, episode.numbering()) { let label = match (resuming, episode.numbering()) {
(true, Some(numbering)) => format!("Resume {numbering}"), (true, Some(numbering)) => format!("Resume {numbering}{at}"),
(true, None) => "Resume".to_string(), (true, None) => format!("Resume{at}"),
(false, Some(numbering)) if title.kind == TitleKind::Series => { (false, Some(numbering)) if title.kind == TitleKind::Series => {
format!("Play {numbering}") format!("Play {numbering}")
} }
@@ -144,16 +268,33 @@ fn header(
if ui::accent_button(ui, &label).clicked() { if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode))); actions.push(Action::Play(PlaybackTarget::new(title, episode)));
} }
// Beside the button it is the alternative to.
if resuming
&& ui
.button("Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline { if all_offline {
if ui.button("Make show online-only").clicked() { if ui
.button("Delete show")
.on_hover_text("Delete the offline copies; keep the online source")
.clicked()
{
for key in title_keys { for key in title_keys {
actions.push(Action::RemoveDownload(key, false)); actions.push(Action::RemoveDownload(key, false));
} }
} }
} else if ui } else if ui
.button("Make show offline") .button("Download show")
.on_hover_text("Download every episode for disconnected playback") .on_hover_text("Download every episode for disconnected playback")
.clicked() .clicked()
{ {
@@ -182,20 +323,6 @@ fn header(
} else if all_offline { } else if all_offline {
ui.label(ui::muted("Available offline")); ui.label(ui::muted("Available offline"));
} }
if resuming && let Some(at) = episode.resume_at() {
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
if ui
.button("Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
}
} }
// The way out of a match the scorer would not make, or made // The way out of a match the scorer would not make, or made
@@ -215,31 +342,161 @@ fn header(
}); });
if let Some(found) = &found { if let Some(found) = &found {
ui.add_space(14.0); ui.add_space(theme::space::L);
description(ui, found); description(ui, found);
} }
}); });
}); });
if let Some((texture, index)) = backdrop {
ui.add_space(theme::space::L);
band(ui, &texture, index);
}
}
/// The picture again, across the whole width, behind the header.
///
/// The one piece of this page that had to wait for artwork to exist at all: the
/// providers already hand back a picture and its shape, so a backdrop costs no
/// new request — only somewhere to put it.
///
/// Three shapes over one another, in this order: the picture, a flat veil of the
/// page colour so text laid over it stays readable whatever the still happens to
/// be, and a fade into the page along the bottom so the band ends rather than
/// stops.
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
// Full width of the window, not of the content: the panel's own margin is
// what the band has to reach past, or it reads as a picture in a box.
const BLEED: f32 = 18.0;
let content = ui.min_rect();
let rect = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
egui::pos2(ui.max_rect().right() + BLEED, content.bottom()),
);
// Past the panel margin, or the bleed is trimmed off at exactly the edge it
// exists to cross.
let painter = ui.painter().with_clip_rect(rect);
// Every layer of the band goes into the one slot reserved before the header
// was laid out. Painting the veils here, after the fact, would put them over
// the name and the overview as well as the still — which is the picture
// being readable at the cost of the text it exists behind.
let mut layers: Vec<egui::Shape> = vec![
egui::epaint::RectShape::filled(rect, egui::CornerRadius::ZERO, egui::Color32::WHITE)
.with_texture(
texture.id(),
super::cover_uv(texture.size_vec2(), rect.size()),
)
.into(),
];
// Heavier than it looks: a still is arbitrary, and the one it lands on may
// be a white frame. This is the difference between a hero and an unreadable
// page.
//
// Two coats rather than one heavy one. The first is flat and near-opaque —
// enough on its own that body text over the busiest frame still reads. The
// second is the picture's own contrast being knocked back further behind the
// left half, where the poster, the name and the overview actually sit; the
// right half keeps more of the still, so the band is still a picture.
//
// Heavier still when there is no fade coming, since then these are the only
// things standing between the text and the picture.
let ramps = theme::ramps_on();
layers.push(
egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::ZERO,
// Lighter with the ramps on: the horizontal one below is solid
// where the text is, so this coat only has to calm the picture's
// right side, not make it disappear.
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
)
.into(),
);
if ramps {
let text_side = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + rect.width() * 0.72, rect.bottom()),
);
layers.push(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
theme::Direction::Horizontal,
// Solid at the left, where the poster and the text are.
false,
));
}
// A viewer with ramps off is on a panel that bands them; a 120-point ramp
// of one hue is exactly the case that shows it. The band then ends at its
// own edge, which is the honest version of the same thing.
if ramps {
let fade =
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max);
layers.push(theme::fade_shape(
ui.ctx(),
fade,
theme::background(),
theme::Direction::Vertical,
// Solid at the bottom, where the page it hands over to is.
true,
));
}
painter.set(index, egui::Shape::Vec(layers));
} }
/// What the provider had to say. Only ever drawn under a match. /// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) { fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
if !found.genres.is_empty() { if !found.genres.is_empty() {
ui.label(ui::muted(found.genres.join(" · "))); ui.horizontal_wrapped(|ui| {
ui.add_space(6.0); ui.spacing_mut().item_spacing.x = 6.0;
for genre in &found.genres {
ui::widgets::chip(ui, genre);
}
});
ui.add_space(theme::space::M);
} }
if let Some(overview) = &found.overview { if let Some(overview) = &found.overview {
ui.add( // Four lines, then "More". A provider's synopsis runs to three
egui::Label::new( // paragraphs on a long show, and all of them pushed the episodes —
egui::RichText::new(overview) // what the page is for — below the fold.
.size(13.0) const LINES: usize = 4;
.color(theme::text()), let id = ui.id().with(("overview", &found.remote_id));
) let open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
.wrap(), let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Label.font(),
theme::text(),
ui.available_width(),
); );
if !open {
job.wrap.max_rows = LINES;
job.wrap.overflow_character = Some('…');
}
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
let clipped = galley.elided;
ui.label(galley);
if clipped || open {
let toggle = ui.add(
egui::Label::new(
theme::Role::Caption
.rich(if open { "Less" } else { "More" })
.color(theme::accent()),
)
.sense(egui::Sense::click()),
);
if toggle
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
ui.data_mut(|data| data.insert_temp(id, !open));
}
}
} }
if let Some(url) = &found.url { if let Some(url) = &found.url {
ui.add_space(8.0); ui.add_space(theme::space::M);
ui.hyperlink_to( ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())), ui::muted(format!("More on {}", found.provider.label())),
url, url,
@@ -275,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}")); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
} }
// Only when it disagrees with what is on disk: "25 episodes · 25 // Only when it disagrees with what is on disk: "25 episodes · 25
// episodes" tells nobody anything, but "12 episodes · 25 on AniList" // episodes" tells nobody anything, but "12 episodes · 25 on AniList"
@@ -290,7 +547,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.join(" · ") parts.join(" · ")
} }
/// One line per file: click anywhere on it to play. /// How big an episode's still is drawn.
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
/// One row per file: its still, its name and what it is about, and the two
/// things that can be done to it besides playing it.
fn episode_row( fn episode_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
art: &mut Art<'_>, art: &mut Art<'_>,
@@ -309,165 +570,311 @@ fn episode_row(
) )
}); });
let watched = episode.is_watched(); let watched = episode.is_watched();
egui::Frame::new() let row_id = ui.id().with(("episode", &episode.node.link_id));
.fill(if watched {
theme::background()
} else {
theme::card()
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
// An explicit target rather than a clickable row: the row also
// carries a checkbox, and a click that means "seen" must never
// be one that starts a 1.4 GiB stream instead.
if ui
.add(
egui::Button::new(egui::RichText::new("▶").size(13.0))
.fill(theme::card_hover()),
)
.on_hover_text("Play")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
let key = key_of(episode);
let current = offline.downloads.iter().find(|item| item.key == key);
if offline.files.contains(&key) {
if ui
.small_button("Online-only")
.on_hover_text("Delete the offline copy; keep the online source")
.clicked()
{
actions.push(Action::RemoveDownload(key.clone(), false));
}
ui.label(ui::muted("✓ Offline"));
} else if let Some(item) = current {
match item.state {
DownloadState::Running | DownloadState::Queued => {
if ui.small_button("Pause").clicked() {
actions.push(Action::PauseDownload(key.clone()));
}
ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0)));
}
DownloadState::Paused => {
if ui.small_button("Resume").clicked() {
actions.push(Action::ResumeDownload(key.clone()));
}
ui.label(ui::muted("Paused · partial kept"));
}
DownloadState::Cancelled | DownloadState::Failed(_) => {
if ui.small_button("Resume").clicked() {
actions.push(Action::ResumeDownload(key.clone()));
}
ui.label(ui::muted("Partial"));
}
DownloadState::Completed => {
ui.label(ui::muted("✓ Offline"));
}
}
} else if ui
.small_button("Download")
.on_hover_text("Make available offline")
.clicked()
{
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
title, episode,
)]));
}
let numbering = episode let frame = egui::Frame::new()
.numbering() .corner_radius(egui::CornerRadius::same(theme::radius::LG))
.or_else(|| season.number.map(|number| format!("S{number:02}"))) .inner_margin(egui::Margin::same(8));
.unwrap_or_default(); let mut prepared = frame.begin(ui);
ui.add_sized( {
[66.0, 18.0], let ui = &mut prepared.content_ui;
egui::Label::new( ui.set_width(ui.available_width());
egui::RichText::new(numbering) ui.horizontal_top(|ui| {
.monospace() still(ui, art, title, episode, watched, STILL, actions);
.color(theme::muted()), ui.add_space(theme::space::S);
),
); let controls = 96.0;
let text_width = (ui.available_width() - controls).max(120.0);
ui.vertical(|ui| {
ui.set_width(text_width);
ui.spacing_mut().item_spacing.y = 3.0;
// The provider's name for the episode, when there is one: // The provider's name for the episode, when there is one:
// "The Immortal Legion" reads as an episode, and // "The Immortal Legion" reads as an episode, and
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename. // "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
let numbering = episode
.numbering()
.or_else(|| season.number.map(|number| format!("S{number:02}")))
.unwrap_or_default();
let name = found let name = found
.as_ref() .as_ref()
.and_then(|(name, _, _)| name.clone()) .and_then(|(name, _, _)| name.clone())
.unwrap_or_else(|| episode.detail().to_string()); .unwrap_or_else(|| episode.detail().to_string());
let label = egui::RichText::new(name).color(if watched { let mut job = egui::text::LayoutJob::default();
theme::muted() if !numbering.is_empty() {
} else { job.append(
theme::text() &format!("{numbering} "),
}); 0.0,
let hover = match &found { egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
// The synopsis, where the provider has one — AniList does );
// not, and the filename is worth showing either way.
Some((_, Some(overview), _)) => {
format!("{}\n\n{overview}", episode.node.name)
}
_ => episode.node.name.clone(),
};
ui.add(egui::Label::new(label).truncate())
.on_hover_text(hover);
if let Some((_, _, Some(air_date))) = &found {
ui.label(ui::muted(air_date.clone()));
} }
job.append(
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { &name,
let mut checked = watched; 0.0,
if ui egui::TextFormat::simple(
.checkbox(&mut checked, "") theme::Role::Body.font(),
.on_hover_text(if watched { if watched {
"Mark unwatched" theme::muted()
} else { } else {
"Mark watched" theme::text()
}) },
.changed() ),
{ );
actions.push(Action::SetWatched { job.wrap.max_rows = 1;
share_id: episode.node.share_id.clone(), job.wrap.overflow_character = Some('…');
link_id: episode.node.link_id.clone(), ui.add(egui::Label::new(job).truncate())
watched: checked, .on_hover_text(&episode.node.name);
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
match episode.node.size { ui.label(ui::muted(details(
// A file the share says is empty is one that will not episode,
// play — an upload that never finished, usually. Worth found.as_ref().and_then(|f| f.2.as_deref()),
// saying so on the row rather than only when a click on )));
// it comes back with "has no content".
Some(0) => { if let Some((_, Some(overview), _)) = &found {
ui.label( let mut job = egui::text::LayoutJob::simple(
egui::RichText::new("empty") overview.clone(),
.size(12.0) theme::Role::Caption.font(),
.color(theme::danger()), theme::muted(),
) text_width,
.on_hover_text( );
"The share reports no content for this file; it cannot be played.", job.wrap.max_rows = 2;
); job.wrap.overflow_character = Some('…');
} ui.add(egui::Label::new(job)).on_hover_text(overview);
Some(size) => { }
ui.label(ui::muted(ui::format_size(size))); });
}
None => {} // A fixed height, the still's. `with_layout` would hand the
} // buttons all the height left in the scroll area's viewport and
if let Some(at) = episode.resume_at() { // centre them in it, so whichever row sat highest on screen
ui.label( // stretched down to the bottom of the window.
egui::RichText::new(format!("resume {}", ui::format_time(at))) let cluster = egui::vec2(ui.available_width(), STILL.y);
.size(12.0) let layout = egui::Layout::right_to_left(egui::Align::Center);
.color(theme::accent()), ui.allocate_ui_with_layout(cluster, layout, |ui| {
); if ui::widgets::watched_mark(ui, watched)
} .on_hover_text(if watched {
}); "Mark unwatched"
} else {
"Mark watched"
})
.clicked()
{
actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
watched: !watched,
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
ui.add_space(theme::space::S);
download(ui, title, episode, offline, actions);
}); });
}); });
ui.add_space(4.0); }
// The row lights up under the pointer, so it is clear which file the
// buttons on the right belong to in a list of thirty.
let response = prepared.allocate_space(ui);
let lit = ui.ctx().animate_bool_with_time(
row_id,
response.hovered() || response.contains_pointer(),
theme::motion::HOVER,
);
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
prepared.paint(ui);
ui.add_space(theme::space::XXS);
}
/// The grey line under an episode's name: when it aired, how big it is, and
/// where it would resume.
fn details(episode: &Episode, air_date: Option<&str>) -> String {
let mut parts = Vec::new();
if let Some(at) = episode.resume_at() {
let left = episode
.watch
.and_then(|watch| watch.duration_secs)
.map(|duration| format!(" · {} left", ui::format_time(duration - at)))
.unwrap_or_default();
parts.push(format!("Resume at {}{left}", ui::format_time(at)));
} else if episode.is_watched() {
parts.push("Watched".into());
}
if let Some(date) = air_date {
parts.push(date.to_owned());
}
match episode.node.size {
// A file the share says is empty is one that will not play — an upload
// that never finished, usually. Worth saying on the row rather than
// only when a click on it comes back with "has no content".
Some(0) => parts.push("empty — this file cannot be played".into()),
Some(size) => parts.push(ui::format_size(size)),
None => {}
}
parts.join(" · ")
}
/// The still, which is also the play button.
///
/// An explicit target rather than a clickable row: the row also carries the
/// watched mark and the download button, and a click that meant either of
/// those must never be one that starts a 1.4 GiB stream instead.
pub(crate) fn still(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
episode: &Episode,
watched: bool,
size: egui::Vec2,
actions: &mut Vec<Action>,
) {
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(theme::radius::MD);
let painter = ui.painter();
painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is
// twenty-five requests, and most of them are never scrolled to.
if let Some(texture) = art.still(&title.key, episode) {
let tint = if watched {
egui::Color32::from_gray(150)
} else {
egui::Color32::WHITE
};
painter.add(
egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
}
if response.gained_focus() {
response.scroll_to_me(None);
}
let hover = ui.ctx().animate_bool_with_time(
response.id,
response.hovered() || response.has_focus(),
theme::motion::HOVER,
);
if hover > 0.0 {
painter.rect_filled(
rect,
radius,
egui::Color32::from_black_alpha((90.0 * hover) as u8),
);
}
if response.has_focus() {
painter.rect_stroke(
rect,
radius,
egui::Stroke::new(2.0, theme::accent()),
egui::StrokeKind::Inside,
);
}
// A play mark, always faintly there and full under the pointer.
let centre = rect.center();
painter.circle_filled(
centre,
18.0,
egui::Color32::from_black_alpha((110.0 + 80.0 * hover) as u8),
);
let ink = egui::Color32::WHITE.gamma_multiply(0.75 + 0.25 * hover);
let r = 7.0;
painter.add(egui::Shape::convex_polygon(
vec![
centre + egui::vec2(-r * 0.6, -r),
centre + egui::vec2(r, 0.0),
centre + egui::vec2(-r * 0.6, r),
],
ink,
egui::Stroke::NONE,
));
if let Some(progress) = episode.progress() {
let track = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - 4.0),
rect.right_bottom(),
);
let painter = painter.with_clip_rect(track);
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
let mut played = rect;
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
theme::accent_fill(&painter, played, radius, 0.0);
}
if response
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if episode.resume_at().is_some() {
"Resume"
} else {
"Play"
})
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
}
/// One button that is the file's whole offline story: download it, see it
/// coming, pause it, or delete the copy.
fn download(
ui: &mut egui::Ui,
title: &Title,
episode: &Episode,
offline: &OfflineView<'_>,
actions: &mut Vec<Action>,
) {
use ui::widgets::{DownloadGlyph, download_button};
let key = key_of(episode);
let current = offline.downloads.iter().find(|item| item.key == key);
if offline.files.contains(&key) {
if download_button(ui, DownloadGlyph::Done)
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
.clicked()
{
actions.push(Action::RemoveDownload(key, false));
}
return;
}
match current.map(|item| (&item.state, item.percent())) {
Some((DownloadState::Running | DownloadState::Queued, fraction)) => {
if download_button(ui, DownloadGlyph::Progress(fraction))
.on_hover_text(format!(
"Downloading, {:.0}%. Click to pause.",
fraction * 100.0
))
.clicked()
{
actions.push(Action::PauseDownload(key));
}
}
Some((
DownloadState::Paused | DownloadState::Cancelled | DownloadState::Failed(_),
fraction,
)) => {
if download_button(ui, DownloadGlyph::Paused(fraction))
.on_hover_text(format!(
"Stopped at {:.0}%. Click to carry on.",
fraction * 100.0
))
.clicked()
{
actions.push(Action::ResumeDownload(key));
}
}
Some((DownloadState::Completed, _)) => {
download_button(ui, DownloadGlyph::Done).on_hover_text("Available offline");
}
None => {
if download_button(ui, DownloadGlyph::Download)
.on_hover_text("Download, to play without a connection")
.clicked()
{
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
title, episode,
)]));
}
}
}
} }
fn key_of(episode: &Episode) -> DownloadKey { fn key_of(episode: &Episode) -> DownloadKey {
+267
View File
@@ -0,0 +1,267 @@
//! Short messages in the corner, for a few seconds.
//!
//! They replaced a single status line along the bottom of the window, which had
//! three problems a stack does not: a second message wiped the first before it
//! could be read, an error looked like any other line in a slightly different
//! grey, and the line took a strip of the page with it every time it appeared.
use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::app::Action;
use crate::theme;
/// How long a message stays up.
const SECONDS: f64 = 5.0;
/// How long an error stays up. Longer: it is more likely to be read twice.
const ERROR_SECONDS: f64 = 9.0;
/// How many fit at once. A burst beyond that drops the oldest.
const MOST: usize = 4;
/// How long one takes to arrive and to leave.
const FADE: f64 = theme::motion::FADE as f64;
const WIDTH: f32 = 340.0;
pub struct Toast {
id: u64,
text: String,
error: bool,
at: f64,
/// A button on the toast — "Undo" — and what it does.
action: Option<(&'static str, Action)>,
}
/// The messages on screen.
#[derive(Default)]
pub struct Toasts {
items: Vec<Toast>,
next: u64,
}
impl Toasts {
pub fn push(&mut self, now: f64, text: impl Into<String>, error: bool) {
self.push_inner(now, text.into(), error, None);
}
/// A message with a button on it, which runs `action` when pressed.
pub fn push_with(
&mut self,
now: f64,
text: impl Into<String>,
label: &'static str,
action: Action,
) {
self.push_inner(now, text.into(), false, Some((label, action)));
}
fn push_inner(
&mut self,
now: f64,
text: String,
error: bool,
action: Option<(&'static str, Action)>,
) {
// The same message again — a retry that failed the same way — restarts
// the one already up rather than stacking a copy of it.
if let Some(existing) = self.items.iter_mut().find(|toast| toast.text == text) {
existing.at = now;
existing.error = error;
existing.action = action;
return;
}
self.next += 1;
self.items.push(Toast {
id: self.next,
text,
error,
at: now,
action,
});
if self.items.len() > MOST {
self.items.remove(0);
}
}
/// Draw the stack above `bottom` points from the window's bottom edge, and
/// drop whatever has expired or been closed. Returns the action of a
/// toast whose button was pressed.
pub fn show(&mut self, ctx: &egui::Context, bottom: f32) -> Option<Action> {
let now = ctx.input(|input| input.time);
let pointer = ctx.input(|input| input.pointer.hover_pos());
let mut closed = None;
let mut pressed = None;
let mut hovered = false;
let mut y = ctx.content_rect().bottom() - bottom - 16.0;
let right = ctx.content_rect().right() - 16.0;
// Newest at the bottom, nearest where the eye already is.
for toast in self.items.iter().rev() {
let life = lifetime(toast.error);
let age = now - toast.at;
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32;
let opacity = theme::motion::ease_out(arriving.min(leaving));
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
.order(egui::Order::Foreground)
.pivot(Align2::RIGHT_BOTTOM)
.fixed_pos(egui::pos2(right + (1.0 - opacity) * 24.0, y))
.interactable(true)
.show(ctx, |ui| {
ui.set_opacity(opacity);
card(ui, toast)
});
match area.inner {
Pressed::Close => closed = Some(toast.id),
Pressed::Action => pressed = Some(toast.id),
Pressed::Nothing => {}
}
if pointer.is_some_and(|at| area.response.rect.contains(at)) {
hovered = true;
}
y -= area.response.rect.height() + 8.0;
}
if let Some(id) = closed {
self.items.retain(|toast| toast.id != id);
}
// Pressed once, and gone: an "Undo" left up after it ran is an
// invitation to undo the undo, which it does not do.
let action = pressed.and_then(|id| {
let index = self.items.iter().position(|toast| toast.id == id)?;
self.items.remove(index).action.map(|(_, action)| action)
});
// Under the pointer, nothing leaves: someone is reading it.
if hovered {
for toast in &mut self.items {
let life = lifetime(toast.error);
toast.at = toast.at.max(now - life + 1.5);
}
}
self.items
.retain(|toast| now - toast.at < lifetime(toast.error));
if !self.items.is_empty() {
// Enough frames to animate the edges and to notice an expiry.
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
action
}
}
fn lifetime(error: bool) -> f64 {
if error { ERROR_SECONDS } else { SECONDS }
}
/// Which of a toast's buttons was pressed.
enum Pressed {
Nothing,
Close,
Action,
}
/// One message. Returns which of its buttons was pressed.
fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
let mut pressed = Pressed::Nothing;
let edge = if toast.error {
theme::danger()
} else {
theme::accent()
};
egui::Frame::new()
.fill(theme::surface())
.stroke(Stroke::new(1.0, theme::card_hover()))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin {
left: 16,
right: 10,
top: 10,
bottom: 10,
})
.shadow(egui::Shadow {
offset: [0, 6],
blur: 18,
spread: 0,
color: Color32::from_black_alpha(90),
})
.show(ui, |ui| {
ui.set_width(WIDTH);
let frame = ui.max_rect();
// The buttons first, from the right, so the text wraps in what
// they leave rather than pushing them off the card.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let (rect, response) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::click());
let ink = if response.hovered() {
theme::text()
} else {
theme::muted()
};
let d = 4.0;
let c = rect.center();
ui.painter().line_segment(
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
Stroke::new(1.5, ink),
);
ui.painter().line_segment(
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
Stroke::new(1.5, ink),
);
if response.on_hover_text("Dismiss").clicked() {
pressed = Pressed::Close;
}
if let Some((label, _)) = &toast.action {
ui.add_space(theme::space::XS);
if ui
.add(
egui::Button::new(
theme::Role::Label
.rich(*label)
.strong()
.color(theme::accent()),
)
.frame(false),
)
.clicked()
{
pressed = Pressed::Action;
}
}
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.add(
egui::Label::new(theme::Role::Label.rich(&toast.text).color(theme::text()))
.wrap(),
);
});
});
// A strip down the leading edge says which kind it is before a word
// of it is read.
let strip = egui::Rect::from_min_max(
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
);
ui.painter()
.rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge);
});
pressed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_repeated_message_restarts_rather_than_stacks() {
let mut toasts = Toasts::default();
toasts.push(0.0, "could not open", true);
toasts.push(3.0, "could not open", true);
assert_eq!(toasts.items.len(), 1);
assert_eq!(toasts.items[0].at, 3.0);
}
#[test]
fn a_burst_keeps_only_the_newest() {
let mut toasts = Toasts::default();
for index in 0..10 {
toasts.push(0.0, format!("message {index}"), false);
}
assert_eq!(toasts.items.len(), MOST);
assert_eq!(toasts.items.last().unwrap().text, "message 9");
}
}
+256 -105
View File
@@ -56,7 +56,7 @@ pub fn full(
) { ) {
seek_bar(ui, playback, actions, 6.0); seek_bar(ui, playback, actions, 6.0);
times(ui, playback, true); times(ui, playback, true);
ui.add_space(6.0); ui.add_space(theme::space::S);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_FULL); ui.set_min_height(ROW_HEIGHT_FULL);
@@ -65,7 +65,7 @@ pub fn full(
// hand reaches for it. // hand reaches for it.
step_button( step_button(
ui, ui,
"⏮", egui_phosphor::regular::SKIP_BACK,
"Previous (P)", "Previous (P)",
neighbours.previous, neighbours.previous,
actions, actions,
@@ -76,14 +76,21 @@ pub fn full(
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions); skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
play_pause(ui, playback, actions, 44.0); play_pause(ui, playback, actions, 44.0);
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions); skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| { step_button(
a.push(Action::PlayAdjacent(Adjacent::Next)); ui,
}); egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
ui.add_space(12.0); ui.add_space(theme::space::L);
if playback.seeking || !playback.loaded { if playback.seeking || playback.buffering || !playback.loaded {
ui.add(egui::Spinner::new().size(16.0)); ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(if playback.loaded { ui.label(ui::muted(if playback.loaded && playback.seeking {
"seeking" "seeking"
} else { } else {
"buffering" "buffering"
@@ -93,95 +100,153 @@ pub fn full(
// Right cluster: everything that is a choice rather than a move. // Right cluster: everything that is a choice rather than a move.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions); track_menus(ui, playback, actions);
ui.add_space(8.0); ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions); chapter_menu(ui, playback, actions);
ui.add_space(8.0); ui.add_space(theme::space::M);
volume(ui, playback, actions); volume(ui, playback, actions);
}); });
}); });
} }
/// The strip under the library: what is playing, and back to it. /// The strip under the library: what is playing, and back to it.
/// How big the live picture in the mini-player is drawn.
pub const MINI_PICTURE: Vec2 = Vec2::new(112.0, 63.0);
pub fn mini( pub fn mini(
ui: &mut egui::Ui, ui: &mut egui::Ui,
playback: &Playback, playback: &Playback,
picture: Option<egui::TextureId>,
neighbours: Neighbours, neighbours: Neighbours,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI); // The way back to the picture, first, because leaving the player page
// with Esc is otherwise a one-way trip: playback carries on with
// The way back to the picture. First, and a real button rather than a // nowhere to watch it. The picture itself when there is one — it is
// label, because leaving the player page with Esc is otherwise a // both the reminder that something is playing and the button.
// one-way trip: playback carries on with nowhere to watch it. match picture {
if ui Some(texture) => {
.add( let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
egui::Button::new(egui::RichText::new("Back to video").size(13.0)) let painter = ui.painter();
.fill(theme::card_hover()) let radius = CornerRadius::same(theme::radius::SM);
.corner_radius(CornerRadius::same(8)), painter.rect_filled(rect, radius, Color32::BLACK);
) painter.add(
.on_hover_text("Show the picture again") egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
.clicked() texture,
{ Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
actions.push(Action::Goto(Page::Player)); ),
} );
ui.add_space(10.0); let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
ui.vertical(|ui| { response.hovered(),
// Back to what is playing: after ten minutes of browsing, the bar theme::motion::HOVER,
// is the only thing that still knows which title this came from. );
if ui if hover > 0.0 {
.add( painter.rect_filled(
egui::Label::new( rect,
egui::RichText::new(&playback.target.title_name) radius,
.size(14.0) Color32::from_black_alpha((110.0 * hover) as u8),
.strong(), );
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Expand",
theme::Role::Caption.font(),
Color32::WHITE.gamma_multiply(hover),
);
painter.rect_stroke(
rect,
radius,
egui::Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
egui::StrokeKind::Inside,
);
}
if response.on_hover_text("Show the picture again").clicked() {
actions.push(Action::Goto(Page::Player));
}
}
None => {
if ui
.add(
egui::Button::new(theme::Role::Label.rich("Back to video"))
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(theme::radius::MD)),
) )
.sense(Sense::click()), .on_hover_text("Show the picture again")
) .clicked()
.on_hover_text("Show this title") {
.clicked() actions.push(Action::Goto(Page::Player));
{ }
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
} }
ui.label(ui::muted(playback.target.caption())); }
}); ui.add_space(theme::space::L);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.vertical(|ui| {
if ui.button("Stop").clicked() { // Back to what is playing: after ten minutes of browsing, the bar
actions.push(Action::Player(Command::Stop)); // is the only thing that still knows which title this came from.
} if ui
ui.add_space(6.0); .add(
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| { egui::Label::new(
a.push(Action::PlayAdjacent(Adjacent::Next)); theme::Role::Body.rich(&playback.target.title_name).strong(),
)
.sense(Sense::click()),
)
.on_hover_text("Show this title")
.clicked()
{
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
}
ui.label(ui::muted(playback.target.caption()));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Stop").clicked() {
actions.push(Action::Player(Command::Stop));
}
ui.add_space(theme::space::S);
step_button(
ui,
egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
play_pause(ui, playback, actions, 34.0);
step_button(
ui,
egui_phosphor::regular::SKIP_BACK,
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
ui.add_space(theme::space::M);
track_menus(ui, playback, actions);
ui.add_space(theme::space::S);
volume(ui, playback, actions);
if playback.seeking || playback.buffering || !playback.loaded {
ui.add_space(theme::space::S);
ui.add(egui::Spinner::new().size(14.0));
}
});
}); });
play_pause(ui, playback, actions, 34.0); ui.add_space(theme::space::XS);
step_button( seek_bar(ui, playback, actions, 5.0);
ui, times(ui, playback, false);
"⏮",
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
ui.add_space(8.0);
track_menus(ui, playback, actions);
ui.add_space(6.0);
volume(ui, playback, actions);
if playback.seeking || !playback.loaded {
ui.add_space(6.0);
ui.add(egui::Spinner::new().size(14.0));
}
}); });
}); });
ui.add_space(4.0);
seek_bar(ui, playback, actions, 5.0);
times(ui, playback, false);
} }
/// The one button whose position should never move. /// The one button whose position should never move.
@@ -287,7 +352,7 @@ fn step_button(
) { ) {
let response = ui.add_enabled( let response = ui.add_enabled(
enabled, enabled,
egui::Button::new(egui::RichText::new(label).size(15.0)).min_size(Vec2::new(38.0, 30.0)), egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
); );
if response.on_hover_text(hover).clicked() { if response.on_hover_text(hover).clicked() {
push(actions); push(actions);
@@ -363,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
} }
let icon = if playback.muted || playback.volume <= 0.0 { let icon = if playback.muted || playback.volume <= 0.0 {
"🔇" egui_phosphor::regular::SPEAKER_SLASH
} else if playback.volume < 50.0 {
egui_phosphor::regular::SPEAKER_LOW
} else { } else {
"🔊" egui_phosphor::regular::SPEAKER_HIGH
}; };
if ui if ui
.button(icon) .button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume)) .on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.clicked() .clicked()
{ {
@@ -433,6 +500,32 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
} }
} }
/// How fast it plays. Says so on the button only when it is not normal speed,
/// because that is the one case worth noticing at a glance.
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let label = if (playback.speed - 1.0).abs() < 1e-6 {
"Speed".to_owned()
} else {
format!("Speed · {}", ui::format_speed(playback.speed))
};
ui.menu_button(label, |ui| {
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
if ui
.selectable_label(
(playback.speed - speed).abs() < 1e-6,
ui::format_speed(speed),
)
.clicked()
{
actions.push(Action::SetSpeed(speed));
ui.close();
}
}
})
.response
.on_hover_text("Playback speed ([ and ])");
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for /// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else. /// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) { fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
@@ -474,66 +567,101 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
let (rect, response) = let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag()); ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
let hovered = response.hovered() || response.dragged(); let active = response.hovered() || response.dragged();
let track = Rect::from_center_size( // Grows rather than jumps when the pointer arrives.
rect.center(), let grow =
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }), ui.ctx()
); .animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER);
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
let radius = CornerRadius::same(theme::radius::BAR);
let painter = ui.painter(); let painter = ui.painter();
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52)); // From the palette rather than a fixed grey: the same bar sits on the
// video's dark scrim and on a light theme's transport strip.
painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35));
if let Some(progress) = playback.progress() { let duration = playback.duration.filter(|duration| *duration > 0.0);
// What is already read ahead, a shade lighter than the empty track: on a
// link this slow, whether a jump lands in it is the difference between an
// instant seek and a fetch.
if let (Some(duration), Some(end)) = (duration, playback.buffered)
&& end > playback.position
{
let from = (playback.position / duration).clamp(0.0, 1.0) as f32;
let to = (end / duration).clamp(0.0, 1.0) as f32;
let buffered = Rect::from_min_max(
egui::pos2(track.left() + track.width() * from, track.top()),
egui::pos2(track.left() + track.width() * to, track.bottom()),
);
painter.rect_filled(buffered, radius, theme::muted().gamma_multiply(0.30));
}
let pointer = response
.interact_pointer_pos()
.or_else(|| response.hover_pos())
.map(|position| ((position.x - track.left()) / track.width()).clamp(0.0, 1.0));
// Where the pointer would land, lighter than what has been played: the bar
// shows the jump before it is made.
if let (Some(fraction), true) = (pointer, active) {
let mut ghost = track;
ghost.set_width(track.width() * fraction);
painter.rect_filled(ghost, radius, theme::muted().gamma_multiply(0.45));
}
// While dragging, the knob follows the pointer even though the seek waits
// for the release — see below. A knob that stays put under a moving hand
// reads as a bar that is not listening.
let shown = match (response.dragged(), pointer) {
(true, Some(fraction)) => Some(fraction),
_ => playback.progress(),
};
if let Some(progress) = shown {
let mut played = track; let mut played = track;
played.set_width(track.width() * progress); played.set_width(track.width() * progress);
// The gradient is sized to the *played* part, not to the whole bar, so // The gradient is sized to the *played* part, not to the whole bar, so
// the far end of it arrives at the playhead rather than at the end of // the far end of it arrives at the playhead rather than at the end of
// the file. A bar that only ever shows the first fifth of its own ramp // the file. A bar that only ever shows the first fifth of its own ramp
// does not read as a gradient at all. // does not read as a gradient at all.
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0); theme::accent_fill(painter, played, radius, 0.0);
painter.circle_filled( painter.circle_filled(
egui::pos2(played.right(), track.center().y), egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 }, 5.0 + 3.0 * grow,
Color32::WHITE, Color32::WHITE,
); );
} }
// Seeking needs a duration: without one there is nothing for a fraction of // Seeking needs a duration: without one there is nothing for a fraction of
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway. // the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else { let Some(duration) = duration else {
return; return;
}; };
// Chapter marks, over the bar rather than under it: an opening is two // Chapter marks, cut into the bar rather than drawn on it: an opening is
// minutes of a twenty-four minute file, and the point of the mark is to be // two minutes of a twenty-four minute file, and the point of the mark is to
// able to aim just past it. // be able to aim just past it.
for chapter in playback.chapters.iter().skip(1) { for chapter in playback.chapters.iter().skip(1) {
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32; let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
let x = track.left() + track.width() * fraction; let x = track.left() + track.width() * fraction;
painter.rect_filled( painter.rect_filled(
Rect::from_min_size( Rect::from_min_size(
egui::pos2(x - 1.0, track.top() - 1.0), egui::pos2(x - 1.5, track.top() - 1.0),
Vec2::new(2.0, track.height() + 2.0), Vec2::new(3.0, track.height() + 2.0),
), ),
CornerRadius::ZERO, CornerRadius::ZERO,
Color32::from_black_alpha(180), Color32::from_black_alpha(200),
); );
} }
// On release, not on every frame of the drag: each seek cancels outstanding // On release, not on every frame of the drag: each seek cancels outstanding
// read-ahead and refetches, so a dragged bar that seeks continuously spends // read-ahead and refetches, so a dragged bar that seeks continuously spends
// the whole drag throwing away blocks it just paid for. // the whole drag throwing away blocks it just paid for.
if let Some(position) = response.interact_pointer_pos() if let Some(fraction) = pointer
&& (response.clicked() || response.drag_stopped()) && (response.clicked() || response.drag_stopped())
{ {
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration))); actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
} }
if response.hovered() if let (Some(fraction), true) = (pointer, active) {
&& let Some(position) = response.hover_pos() let at = f64::from(fraction) * duration;
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
let at = fraction * duration;
// The chapter under the pointer, when there is one: "42:10 · OP" tells // The chapter under the pointer, when there is one: "42:10 · OP" tells
// a viewer what they are about to land in, which is the whole reason to // a viewer what they are about to land in, which is the whole reason to
// aim at a particular part of the bar. // aim at a particular part of the bar.
@@ -545,6 +673,29 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
), ),
None => ui::format_time(at), None => ui::format_time(at),
}; };
response.clone().on_hover_text_at_pointer(hint); bubble(
ui,
egui::pos2(track.left() + track.width() * fraction, track.top() - 10.0),
&hint,
);
} }
} }
/// A small label floating above a point, kept inside the window.
fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
let painter = ui.ctx().layer_painter(egui::LayerId::new(
egui::Order::Tooltip,
egui::Id::new("seek-bubble"),
));
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Label.font(), Color32::WHITE);
let size = galley.size() + Vec2::new(16.0, 8.0);
let screen = ui.ctx().content_rect();
let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0);
let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size);
painter.rect_filled(
frame,
CornerRadius::same(theme::radius::SM),
Color32::from_black_alpha(215),
);
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
}
+369
View File
@@ -0,0 +1,369 @@
//! Controls egui does not have, or has in a shape that does not fit.
//!
//! Each is painted by hand for the same reason [`super::accent_button`] is: an
//! egui widget takes one flat colour, and what makes these readable is either a
//! gradient or a state drawn as a shape rather than as a fill.
use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
use crate::theme;
/// An on/off switch with its label beside it, for settings.
///
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
/// on a card, which is where every setting lives, an unchecked box was not
/// there at all. A switch says "off" with a shape, not with a fill that has to
/// stand out from what is behind it.
pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
const TRACK: Vec2 = Vec2::new(34.0, 20.0);
let galley = ui.painter().layout_no_wrap(
text.to_owned(),
theme::Role::Body.font(),
Color32::PLACEHOLDER,
);
let gap = if text.is_empty() { 0.0 } else { 10.0 };
let size = Vec2::new(
TRACK.x + gap + galley.size().x,
TRACK
.y
.max(galley.size().y)
.max(ui.spacing().interact_size.y),
);
let (rect, mut response) = ui.allocate_exact_size(size, Sense::click());
if response.clicked() {
*on = !*on;
response.mark_changed();
}
response
.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *on, text));
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let track = egui::Rect::from_min_size(
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
TRACK,
);
let radius = CornerRadius::same((TRACK.y / 2.0) as u8);
let painter = ui.painter();
// Off is an outlined track; on is the accent filling it. Both are drawn
// through the switch's travel, so the fill arrives with the knob.
painter.rect_filled(track, radius, theme::card_hover());
painter.rect_stroke(
track,
radius,
Stroke::new(1.0, theme::muted().gamma_multiply(0.6 + 0.4 * hover)),
egui::StrokeKind::Inside,
);
if t > 0.0 {
let mut lit = track;
lit.set_width(TRACK.y + (TRACK.x - TRACK.y) * t);
theme::accent_fill(painter, lit, radius, 0.0);
}
let knob = egui::lerp(
(track.left() + TRACK.y / 2.0)..=(track.right() - TRACK.y / 2.0),
t,
);
let ink = theme::muted().lerp_to_gamma(Color32::WHITE, t);
painter.circle_filled(egui::pos2(knob, track.center().y), TRACK.y / 2.0 - 3.0, ink);
if response.has_focus() {
painter.rect_stroke(
track.expand(2.0),
CornerRadius::same((TRACK.y / 2.0 + 2.0) as u8),
Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Outside,
);
}
let position = egui::pos2(track.right() + gap, rect.center().y - galley.size().y / 2.0);
painter.galley(position, galley, theme::text());
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A round mark that is either a filled tick or an empty ring: "watched".
///
/// The checkbox it replaced had the same invisible-when-off problem as the one
/// [`toggle`] replaced, and a list of episodes is the one place where the
/// unwatched state is the one that most needs to be seen.
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
const SIZE: f32 = 20.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if ui.is_rect_visible(rect) {
let ctx = ui.ctx();
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let painter = ui.painter();
let center = rect.center();
let radius = SIZE / 2.0 - 1.0;
if t > 0.0 {
painter.circle_filled(center, radius, theme::accent().gamma_multiply(t));
}
painter.circle_stroke(
center,
radius,
Stroke::new(
1.5,
theme::muted()
.lerp_to_gamma(theme::text(), hover)
.gamma_multiply(1.0 - t),
),
);
// The tick is drawn in both states: faintly, under the pointer, while
// unwatched — so the mark says what clicking it will do.
let ink = if watched {
theme::on_accent()
} else {
theme::muted().gamma_multiply(hover)
};
if watched || hover > 0.0 {
let points = [
center + Vec2::new(-4.5, 0.2),
center + Vec2::new(-1.4, 3.3),
center + Vec2::new(4.6, -3.2),
];
painter.line(points.to_vec(), Stroke::new(1.8, ink));
}
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A group of settings: a heading, an optional line under it, and a card that
/// holds the rows.
///
/// The libadwaita preferences pattern, which is what `proton-drive-linux`
/// settles on: one card per subject, one row per setting, the setting's name
/// and what it does on the left and the control on the right — so the eye runs
/// down one edge for names and the other for state.
pub fn settings_group<R>(
ui: &mut egui::Ui,
heading: &str,
description: Option<&str>,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
ui.add_space(theme::space::XS);
ui.label(
theme::Role::Subhead
.rich(heading)
.strong()
.color(theme::text()),
);
if let Some(description) = description {
ui.add_space(theme::space::XXS);
ui.label(crate::ui::muted(description));
}
ui.add_space(theme::space::M);
let inner = egui::Frame::new()
.fill(theme::card())
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(16, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
add(ui)
})
.inner;
ui.add_space(theme::space::XXL);
inner
}
/// One row of a [`settings_group`]: what it is on the left, the control on the
/// right. Rows after the first get a hairline above them.
pub fn settings_row<R>(
ui: &mut egui::Ui,
title: &str,
subtitle: Option<&str>,
control: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let top = ui.cursor().top();
// The first row of a card starts at its top margin; anything below that
// has a row above it to be divided from.
if top > ui.max_rect().top() + 1.0 {
let y = top;
ui.painter().hline(
ui.max_rect().x_range(),
y,
Stroke::new(1.0, theme::card_hover()),
);
}
ui.add_space(theme::space::L);
let inner = ui
.horizontal(|ui| {
ui.set_min_height(28.0);
let text_width = (ui.available_width() * 0.62).max(160.0);
ui.vertical(|ui| {
ui.set_max_width(text_width);
ui.spacing_mut().item_spacing.y = 2.0;
ui.label(theme::Role::Body.rich(title).color(theme::text()));
if let Some(subtitle) = subtitle {
ui.add(egui::Label::new(crate::ui::muted(subtitle)).wrap());
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), control)
.inner
})
.inner;
ui.add_space(theme::space::L);
inner
}
/// A row of choices where exactly one is on: pills on a shared track.
///
/// Returns the one clicked, if it was not already the chosen one.
pub fn segmented<T: Copy + PartialEq>(
ui: &mut egui::Ui,
current: T,
choices: &[(T, &str)],
) -> Option<T> {
let mut picked = None;
egui::Frame::new()
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(3))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
ui.horizontal(|ui| {
for (value, label) in choices {
let selected = *value == current;
let text = theme::Role::Label.rich(*label).color(if selected {
theme::on_accent()
} else {
theme::text()
});
let button = egui::Button::new(text)
.corner_radius(CornerRadius::same(theme::radius::SM))
.fill(if selected {
theme::accent()
} else {
Color32::TRANSPARENT
})
.stroke(Stroke::NONE);
if ui.add(button).clicked() && !selected {
picked = Some(*value);
}
}
});
});
picked
}
/// What a [`download_button`] shows.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DownloadGlyph {
/// Not downloaded: an arrow into a tray.
Download,
/// Coming down: a ring filling with how far it has got.
Progress(f32),
/// Stopped part way: the ring, still, with a pause mark.
Paused(f32),
/// Here: a tick.
Done,
}
/// A round icon button for one file's download.
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
const SIZE: f32 = 30.0;
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
let hover =
ui.ctx()
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
let painter = ui.painter();
let c = rect.center();
if hover > 0.0 {
painter.circle_filled(c, SIZE / 2.0, theme::card_hover().gamma_multiply(hover));
}
let ink = theme::muted().lerp_to_gamma(theme::text(), hover);
let stroke = Stroke::new(1.6, ink);
let ring = |painter: &egui::Painter, fraction: f32| {
let radius = 9.0;
painter.circle_stroke(c, radius, Stroke::new(2.0, theme::card_hover()));
let steps = 40;
let points: Vec<egui::Pos2> = (0..=((steps as f32 * fraction.clamp(0.0, 1.0)) as usize))
.map(|step| {
let angle = -std::f32::consts::FRAC_PI_2
+ std::f32::consts::TAU * step as f32 / steps as f32;
c + Vec2::angled(angle) * radius
})
.collect();
if points.len() > 1 {
painter.add(egui::Shape::line(points, Stroke::new(2.0, theme::accent())));
}
};
match glyph {
DownloadGlyph::Download => {
painter.line_segment([c + Vec2::new(0.0, -6.0), c + Vec2::new(0.0, 3.0)], stroke);
painter.line(
vec![
c + Vec2::new(-4.0, -1.0),
c + Vec2::new(0.0, 3.0),
c + Vec2::new(4.0, -1.0),
],
stroke,
);
painter.line_segment([c + Vec2::new(-6.0, 7.0), c + Vec2::new(6.0, 7.0)], stroke);
}
DownloadGlyph::Progress(fraction) => {
ring(painter, fraction);
// Keeps a frame coming while the ring has somewhere to go.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
if hover > 0.0 {
for x in [-2.5, 2.5] {
painter.line_segment(
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
Stroke::new(1.8, ink.gamma_multiply(hover)),
);
}
}
}
DownloadGlyph::Paused(fraction) => {
ring(painter, fraction);
for x in [-2.5, 2.5] {
painter.line_segment(
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
Stroke::new(1.8, ink),
);
}
}
DownloadGlyph::Done => {
painter.line(
vec![
c + Vec2::new(-5.0, 0.0),
c + Vec2::new(-1.5, 3.5),
c + Vec2::new(5.0, -3.5),
],
Stroke::new(2.0, theme::accent().lerp_to_gamma(ink, hover)),
);
}
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new()
.fill(theme::card_hover().gamma_multiply(0.8))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.show(ui, |ui| {
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
})
.response
}
+6 -4
View File
@@ -67,8 +67,10 @@ pub struct VideoSurface {
impl VideoSurface { impl VideoSurface {
/// Attach a renderer to `player` on the current OpenGL context. /// Attach a renderer to `player` on the current OpenGL context.
/// ///
/// `ctx` is woken whenever mpv has a new frame; without that the picture /// `wake` is called whenever mpv has a new frame, and should ask the window
/// would only advance when the viewer moved the mouse. /// for one. Without it the picture would only advance when the viewer moved
/// the mouse. The app's goes through [`crate::engine::Engine::wake`], which
/// knows whether the picture is on screen to be drawn at all.
/// ///
/// # Safety /// # Safety
/// ///
@@ -78,11 +80,11 @@ impl VideoSurface {
pub unsafe fn new( pub unsafe fn new(
player: Arc<Player>, player: Arc<Player>,
gl: Arc<glow::Context>, gl: Arc<glow::Context>,
ctx: egui::Context, wake: impl Fn() + Send + Sync + 'static,
) -> pstr_player::Result<Self> { ) -> pstr_player::Result<Self> {
// SAFETY: forwarded from this function's own contract. // SAFETY: forwarded from this function's own contract.
let mut renderer = unsafe { VideoRenderer::new(player)? }; let mut renderer = unsafe { VideoRenderer::new(player)? };
renderer.on_update(move || ctx.request_repaint()); renderer.on_update(wake);
Ok(Self { Ok(Self {
gl, gl,
+124
View File
@@ -0,0 +1,124 @@
//! The window's size, kept between launches.
//!
//! Size and maximized only. Position is left to the window manager: Wayland
//! does not let a client choose it, and on X11 a remembered position on a
//! monitor that has since been unplugged opens the window off screen.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// What the next launch should open at.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct WindowState {
pub width: f32,
pub height: f32,
pub maximized: bool,
}
/// The smallest size worth restoring. Anything below is a window that was
/// being dragged about when it closed, or a file someone edited by hand.
const SMALLEST: [f32; 2] = [720.0, 480.0];
impl WindowState {
/// The saved state, if there is a sensible one. A missing or broken file
/// means the default size, never an error: this is a convenience.
pub fn load(path: &Path) -> Option<Self> {
let state: Self = pstr_core::config::read_json(path).ok().flatten()?;
state.sensible().then_some(state)
}
fn sensible(&self) -> bool {
self.width.is_finite()
&& self.height.is_finite()
&& self.width >= SMALLEST[0]
&& self.height >= SMALLEST[1]
}
/// Fold this frame's viewport into `last`.
///
/// A fullscreen window says nothing about how big the window should be, and
/// a maximized one says nothing about how big it should be once it is not,
/// so neither changes the size kept.
pub fn observe(ctx: &egui::Context, last: Option<Self>) -> Option<Self> {
let (size, maximized, fullscreen) = ctx.input(|input| {
let viewport = input.viewport();
(
viewport.inner_rect.map(|rect| rect.size()),
viewport.maximized.unwrap_or(false),
viewport.fullscreen.unwrap_or(false),
)
});
if fullscreen {
return last;
}
let (width, height) = match (maximized, last, size) {
(true, Some(last), _) => (last.width, last.height),
(_, _, Some(size)) => (size.x, size.y),
_ => return last,
};
let state = Self {
width,
height,
maximized,
};
state.sensible().then_some(state).or(last)
}
}
/// Writes the state when it changes, once, at exit.
pub struct WindowMemory {
path: PathBuf,
saved: Option<WindowState>,
pub current: Option<WindowState>,
}
impl WindowMemory {
pub fn new(path: PathBuf) -> Self {
let saved = WindowState::load(&path);
Self {
path,
saved,
current: saved,
}
}
pub fn observe(&mut self, ctx: &egui::Context) {
self.current = WindowState::observe(ctx, self.current);
}
pub fn save(&self) {
let Some(current) = self.current.filter(|current| Some(*current) != self.saved) else {
return;
};
if let Err(error) = pstr_core::config::write_json(&self.path, &current) {
tracing::debug!("save the window size: {error}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_window_smaller_than_the_minimum_is_not_restored() {
let path = std::env::temp_dir().join(format!("pstr-window-{}.json", std::process::id()));
let tiny = WindowState {
width: 200.0,
height: 100.0,
maximized: false,
};
pstr_core::config::write_json(&path, &tiny).unwrap();
assert_eq!(WindowState::load(&path), None);
let normal = WindowState {
width: 1400.0,
height: 900.0,
maximized: true,
};
pstr_core::config::write_json(&path, &normal).unwrap();
assert_eq!(WindowState::load(&path), Some(normal));
let _ = std::fs::remove_file(path);
}
}
+517 -3
View File
@@ -9,7 +9,8 @@
use crate::config::{AppDirs, read_json, write_json}; use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result; use crate::error::Result;
/// A palette family: Catppuccin's four, plus the one this app shipped with. /// A palette family: Catppuccin's four, the one this app shipped with, and one
/// borrowed from a menu screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Flavor { pub enum Flavor {
@@ -22,15 +23,18 @@ pub enum Flavor {
Frappe, Frappe,
Macchiato, Macchiato,
Mocha, Mocha,
/// Black, white and one loud red — the Persona 5 menus.
Persona5,
} }
impl Flavor { impl Flavor {
pub const ALL: [Self; 5] = [ pub const ALL: [Self; 6] = [
Self::Proton, Self::Proton,
Self::Mocha, Self::Mocha,
Self::Macchiato, Self::Macchiato,
Self::Frappe, Self::Frappe,
Self::Latte, Self::Latte,
Self::Persona5,
]; ];
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
@@ -40,6 +44,7 @@ impl Flavor {
Self::Frappe => "Catppuccin Frappé", Self::Frappe => "Catppuccin Frappé",
Self::Macchiato => "Catppuccin Macchiato", Self::Macchiato => "Catppuccin Macchiato",
Self::Mocha => "Catppuccin Mocha", Self::Mocha => "Catppuccin Mocha",
Self::Persona5 => "Persona 5",
} }
} }
@@ -50,6 +55,7 @@ impl Flavor {
Self::Frappe => "Dark, warm, the lowest contrast of the three", Self::Frappe => "Dark, warm, the lowest contrast of the three",
Self::Macchiato => "Dark, between Frappé and Mocha", Self::Macchiato => "Dark, between Frappé and Mocha",
Self::Mocha => "Dark, the deepest of Catppuccin's three", Self::Mocha => "Dark, the deepest of Catppuccin's three",
Self::Persona5 => "Black and white with one loud red — pick the Red accent for it",
} }
} }
@@ -82,10 +88,15 @@ pub enum Accent {
Blue, Blue,
Teal, Teal,
Peach, Peach,
/// The flavour's warning colour, used on purpose. Every flavour has one
/// because everything here needs a colour for a failure, and in
/// [`Flavor::Persona5`] it is the point of the palette rather than an
/// afterthought.
Red,
} }
impl Accent { impl Accent {
pub const ALL: [Self; 8] = [ pub const ALL: [Self; 9] = [
Self::Mauve, Self::Mauve,
Self::Pink, Self::Pink,
Self::Sky, Self::Sky,
@@ -94,6 +105,7 @@ impl Accent {
Self::Blue, Self::Blue,
Self::Teal, Self::Teal,
Self::Peach, Self::Peach,
Self::Red,
]; ];
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
@@ -106,6 +118,7 @@ impl Accent {
Self::Blue => "Blue", Self::Blue => "Blue",
Self::Teal => "Teal", Self::Teal => "Teal",
Self::Peach => "Peach", Self::Peach => "Peach",
Self::Red => "Red",
} }
} }
} }
@@ -151,6 +164,427 @@ pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
write_json(&appearance_file(dirs), appearance) write_json(&appearance_file(dirs), appearance)
} }
/// One colour, 8 bits a channel, with no alpha.
///
/// Deliberately not a front end's colour type: this module resolves a choice
/// into colours for *both* clients, and neither egui's `Color32` nor Compose's
/// `Color` may appear here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const BLACK: Self = Self::new(0, 0, 0);
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
/// Packed as `0xAARRGGBB`, opaque — the form both Android and a hex literal
/// want.
pub const fn argb(self) -> u32 {
0xff00_0000 | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
}
}
/// One channel from sRGB into linear light, as the sRGB transfer function
/// defines it. Blending has to happen here: mixing sRGB values directly darkens
/// the middle of a ramp between saturated hues, which is exactly where a
/// gradient is looked at.
fn linear(channel: u8) -> f32 {
let value = channel as f32 / 255.0;
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
/// Back out of linear light, rounded to a channel.
fn gamma(value: f32) -> u8 {
let value = value.clamp(0.0, 1.0);
let encoded = if value <= 0.003_130_8 {
value * 12.92
} else {
1.055 * value.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
}
/// Blend two colours in linear light.
pub fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb {
let blend = |from: u8, to: u8| gamma(linear(from) * (1.0 - t) + linear(to) * t);
Rgb::new(
blend(from.r, to.r),
blend(from.g, to.g),
blend(from.b, to.b),
)
}
/// Relative luminance, in linear light, as WCAG defines it.
pub fn luminance(color: Rgb) -> f32 {
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Rgb, other: Rgb) -> f32 {
let (one, other) = (luminance(one), luminance(other));
(one.max(other) + 0.05) / (one.min(other) + 0.05)
}
/// The least contrast a label gets anywhere along a gradient between two
/// colours. Sampled rather than solved: the worst point is not always an end,
/// because the ink can be brighter than one end and darker than the other.
pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
(0..=4)
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
.fold(f32::INFINITY, f32::min)
}
/// The least contrast the app will accept between a label and the fill under
/// it: WCAG AA for large text, which is what wears the accent — button labels,
/// a tab, a play glyph, never body copy.
pub const MIN_CONTRAST: f32 = 3.0;
/// Every colour a client draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
/// a drawing loop has to think about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
/// Behind everything: the page.
pub background: Rgb,
/// The bars above and below it.
pub surface: Rgb,
/// The deepest colour in the flavour — text fields, wells.
pub sunken: Rgb,
/// Tiles, rows, forms.
pub card: Rgb,
pub card_hover: Rgb,
pub border: Rgb,
pub text: Rgb,
pub muted: Rgb,
/// The one strong colour.
pub accent: Rgb,
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
/// the accent is a single hue and gradients would have nothing to do.
pub accent_alt: Rgb,
/// The accent taken down into the background, for a pressed control and for
/// selection behind text.
pub accent_dim: Rgb,
/// Ink that stays readable on top of the accent. Chosen by the accent's
/// luminance, because Catppuccin's dark flavours have *pale* accents and
/// white-on-pastel is not readable at 13 px.
pub on_accent: Rgb,
pub danger: Rgb,
/// Whether this is a light theme.
pub light: bool,
}
/// One flavour's raw colours, named as Catppuccin names them.
///
/// Stored rather than computed: these are somebody else's palette, and the
/// point of using it is to use it exactly.
#[derive(Clone, Copy)]
struct Ramp {
base: Rgb,
mantle: Rgb,
crust: Rgb,
surface0: Rgb,
surface1: Rgb,
subtext0: Rgb,
text: Rgb,
pink: Rgb,
mauve: Rgb,
sky: Rgb,
sapphire: Rgb,
blue: Rgb,
lavender: Rgb,
teal: Rgb,
green: Rgb,
peach: Rgb,
yellow: Rgb,
red: Rgb,
light: bool,
}
const fn rgb(hex: u32) -> Rgb {
Rgb::new(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
/// The palette this app shipped with, extended to the hues an accent can be.
///
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
/// chosen to sit on a near-black base at roughly the saturation that one does,
/// which is a good deal hotter than any Catppuccin flavour.
const PROTON: Ramp = Ramp {
base: rgb(0x0e0e12),
mantle: rgb(0x17171d),
crust: rgb(0x0a0a0d),
surface0: rgb(0x1e1e26),
surface1: rgb(0x2a2a35),
subtext0: rgb(0x8e8e9c),
text: rgb(0xeaeaf0),
pink: rgb(0xff5fbe),
mauve: rgb(0x7d4dff),
sky: rgb(0x4dd2ff),
sapphire: rgb(0x3a8fc4),
blue: rgb(0x3f6fe0),
lavender: rgb(0xa68cff),
teal: rgb(0x3fd6b8),
green: rgb(0x56d364),
peach: rgb(0xff9a4d),
yellow: rgb(0xffd166),
red: rgb(0xe05561),
light: false,
};
const LATTE: Ramp = Ramp {
base: rgb(0xeff1f5),
mantle: rgb(0xe6e9ef),
crust: rgb(0xdce0e8),
surface0: rgb(0xccd0da),
surface1: rgb(0xbcc0cc),
subtext0: rgb(0x6c6f85),
text: rgb(0x4c4f69),
pink: rgb(0xea76cb),
mauve: rgb(0x8839ef),
sky: rgb(0x04a5e5),
sapphire: rgb(0x209fb5),
blue: rgb(0x1e66f5),
lavender: rgb(0x7287fd),
teal: rgb(0x179299),
green: rgb(0x40a02b),
peach: rgb(0xfe640b),
yellow: rgb(0xdf8e1d),
red: rgb(0xd20f39),
light: true,
};
const FRAPPE: Ramp = Ramp {
base: rgb(0x303446),
mantle: rgb(0x292c3c),
crust: rgb(0x232634),
surface0: rgb(0x414559),
surface1: rgb(0x51576d),
subtext0: rgb(0xa5adce),
text: rgb(0xc6d0f5),
pink: rgb(0xf4b8e4),
mauve: rgb(0xca9ee6),
sky: rgb(0x99d1db),
sapphire: rgb(0x85c1dc),
blue: rgb(0x8caaee),
lavender: rgb(0xbabbf1),
teal: rgb(0x81c8be),
green: rgb(0xa6d189),
peach: rgb(0xef9f76),
yellow: rgb(0xe5c890),
red: rgb(0xe78284),
light: false,
};
const MACCHIATO: Ramp = Ramp {
base: rgb(0x24273a),
mantle: rgb(0x1e2030),
crust: rgb(0x181926),
surface0: rgb(0x363a4f),
surface1: rgb(0x494d64),
subtext0: rgb(0xa5adcb),
text: rgb(0xcad3f5),
pink: rgb(0xf5bde6),
mauve: rgb(0xc6a0f6),
sky: rgb(0x91d7e3),
sapphire: rgb(0x7dc4e4),
blue: rgb(0x8aadf4),
lavender: rgb(0xb7bdf8),
teal: rgb(0x8bd5ca),
green: rgb(0xa6da95),
peach: rgb(0xf5a97f),
yellow: rgb(0xeed49f),
red: rgb(0xed8796),
light: false,
};
const MOCHA: Ramp = Ramp {
base: rgb(0x1e1e2e),
mantle: rgb(0x181825),
crust: rgb(0x11111b),
surface0: rgb(0x313244),
surface1: rgb(0x45475a),
subtext0: rgb(0xa6adc8),
text: rgb(0xcdd6f4),
pink: rgb(0xf5c2e7),
mauve: rgb(0xcba6f7),
sky: rgb(0x89dceb),
sapphire: rgb(0x74c7ec),
blue: rgb(0x89b4fa),
lavender: rgb(0xb4befe),
teal: rgb(0x94e2d5),
green: rgb(0xa6e3a1),
peach: rgb(0xfab387),
yellow: rgb(0xf9e2af),
red: rgb(0xf38ba8),
light: false,
};
/// Persona 5's menus: black, white, and a red that is not asking permission.
///
/// The three colours that matter are `crust`/`base` (black), `text` (white) and
/// `red` — everything between them is that same red bled into the black, which
/// is what keeps a card from reading as grey furniture in a palette that has no
/// grey in it. The other hues exist because an accent can be any of them; they
/// are pushed to full saturation to sit on a black page, which is the one thing
/// this palette has in common with [`PROTON`].
const PERSONA5: Ramp = Ramp {
base: rgb(0x0a0708),
mantle: rgb(0x140d0f),
crust: rgb(0x000000),
surface0: rgb(0x1d1215),
surface1: rgb(0x33191d),
subtext0: rgb(0xb59a9d),
text: rgb(0xf5f2f2),
pink: rgb(0xff2e63),
mauve: rgb(0xb13bd6),
sky: rgb(0x2ec7ff),
sapphire: rgb(0x1f9bd0),
blue: rgb(0x2f5ee0),
lavender: rgb(0xa07bff),
teal: rgb(0x12c2a0),
green: rgb(0x2fbf4f),
peach: rgb(0xff7a1a),
yellow: rgb(0xffc400),
red: rgb(0xe60012),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
Flavor::Proton => PROTON,
Flavor::Latte => LATTE,
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
Flavor::Persona5 => PERSONA5,
}
}
/// The accent, and the hue a gradient in it runs into.
///
/// The partners are neighbours on the wheel rather than complements: a
/// gradient across half the spectrum passes through a colour that belongs
/// to neither end, and on a seek bar that reads as a bug. The exception is
/// [`Accent::PinkSky`], which is the whole point of that entry.
const fn accents(&self, accent: Accent) -> (Rgb, Rgb) {
match accent {
Accent::Mauve => (self.mauve, self.blue),
Accent::Pink => (self.pink, self.mauve),
Accent::Sky => (self.sky, self.sapphire),
Accent::PinkSky => (self.pink, self.sky),
Accent::Lavender => (self.lavender, self.blue),
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
Accent::Red => (self.red, self.pink),
}
}
}
impl Palette {
/// The default, spelled out as a constant so the global has something to
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
/// panic message painted before the engine exists, still gets colours.
pub const PROTON: Self = Self {
background: PROTON.base,
surface: PROTON.mantle,
sunken: PROTON.crust,
card: PROTON.surface0,
card_hover: PROTON.surface1,
border: rgb(0x262630),
text: PROTON.text,
muted: PROTON.subtext0,
accent: PROTON.mauve,
accent_alt: PROTON.blue,
accent_dim: rgb(0x5333ad),
on_accent: PROTON.text,
danger: PROTON.red,
light: false,
};
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
let ramp = Ramp::of(appearance.flavor);
let (accent, partner) = ramp.accents(appearance.accent);
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
// at body-text weight — which makes them mid-luminance, and a
// mid-luminance fill is one that neither black nor white sits on. They
// are taken down in value here, and only here: the hue and the
// saturation are Latte's, so it still looks like Latte, and a label on
// a button is legible.
let deepen = |color: Rgb| {
if ramp.light {
mix(color, Rgb::BLACK, 0.30)
} else {
color
}
};
let accent = deepen(accent);
let accent_alt = if appearance.gradients {
deepen(partner)
} else {
accent
};
// Ink that survives on the accent. The flavour's own reading first —
// pale text on a dark theme — and the opposite when that does not clear
// the bar, which on Catppuccin's dark flavours is most of the time:
// their accents are *pastel*, and white on #f5c2e7 is not a label.
//
// Latte's darkest colour is its body text, and even that is only #4c4f69,
// so the dark side is taken a third of the way to black.
let ink_dark = if ramp.light {
mix(ramp.text, Rgb::BLACK, 0.35)
} else {
ramp.crust
};
let ink_light = if ramp.light { ramp.base } else { ramp.text };
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
ink_light
} else {
ink_dark
};
Self {
background: ramp.base,
surface: ramp.mantle,
sunken: ramp.crust,
card: ramp.surface0,
card_hover: ramp.surface1,
border: ramp.surface1,
text: ramp.text,
muted: ramp.subtext0,
accent,
accent_alt,
// Behind text, so it is the accent taken most of the way back to
// the page: a selection in a saturated hue is a selection nobody
// can read through.
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
on_accent,
danger: ramp.red,
light: ramp.light,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -209,4 +643,84 @@ mod tests {
assert_eq!(flavor.is_light(), flavor == Flavor::Latte); assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
} }
} }
#[test]
fn every_flavour_and_accent_resolves_to_readable_ink() {
for flavor in Flavor::ALL {
for accent in Accent::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent,
gradients: true,
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
ratio >= MIN_CONTRAST,
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
);
}
}
}
#[test]
fn only_the_light_flavour_reports_itself_light() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
..Appearance::default()
});
assert_eq!(palette.light, flavor.is_light());
// And the page is on the right side of the middle either way.
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
}
}
#[test]
fn persona_5_is_black_with_that_red_in_it() {
let palette = Palette::resolve(Appearance {
flavor: Flavor::Persona5,
accent: Accent::Red,
gradients: false,
});
assert_eq!(palette.accent, PERSONA5.red);
assert!(!palette.light);
// Black enough that the artwork is the brightest thing on the page:
// darker than the palette this app shipped with, which is the other
// near-black one.
assert!(luminance(palette.background) < luminance(PROTON.base));
}
#[test]
fn every_flavour_can_wear_its_own_warning_colour() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent: Accent::Red,
gradients: true,
});
// The accent is the flavour's red — deepened on the light flavour,
// where it would otherwise be too bright to put a label on.
assert!(palette.accent.r > palette.accent.b, "{flavor:?}");
assert_ne!(palette.accent, palette.background, "{flavor:?}");
}
}
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
});
assert_eq!(flat.accent, flat.accent_alt);
}
#[test]
fn a_blend_stays_between_the_two_colours_it_blends() {
let (from, to) = (Rgb::new(0, 0, 0), Rgb::new(255, 255, 255));
assert_eq!(mix(from, to, 0.0), from);
assert_eq!(mix(from, to, 1.0), to);
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
}
} }
+175 -27
View File
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
use crate::naming::{self, ParsedName}; use crate::naming::{self, ParsedName};
/// Bump when adding a migration. /// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 7; pub const SCHEMA_VERSION: i64 = 8;
const MIGRATION_V1: &str = r#" const MIGRATION_V1: &str = r#"
CREATE TABLE nodes ( CREATE TABLE nodes (
@@ -146,6 +146,13 @@ CREATE TABLE title_track_prefs (
); );
"#; "#;
/// A language cannot tell two English subtitle tracks apart — "Signs & Songs"
/// and "Full Subtitles" — so the track's title is remembered beside it.
const MIGRATION_V8: &str = r#"
ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT;
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
"#;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineFile { pub struct OfflineFile {
pub revision_id: String, pub revision_id: String,
@@ -157,6 +164,12 @@ pub struct TitleTrackPrefs {
pub audio_language: Option<String>, pub audio_language: Option<String>,
pub subtitle_language: Option<String>, pub subtitle_language: Option<String>,
pub subtitles: bool, pub subtitles: bool,
/// The name the muxer gave the chosen audio track, when it had one. What
/// picks between two tracks in the same language on the next episode.
#[serde(default)]
pub audio_title: Option<String>,
#[serde(default)]
pub subtitle_title: Option<String>,
} }
impl Default for TitleTrackPrefs { impl Default for TitleTrackPrefs {
fn default() -> Self { fn default() -> Self {
@@ -164,10 +177,26 @@ impl Default for TitleTrackPrefs {
audio_language: None, audio_language: None,
subtitle_language: None, subtitle_language: None,
subtitles: true, subtitles: true,
audio_title: None,
subtitle_title: None,
} }
} }
} }
/// One `title_track_prefs` row, its columns starting at `first`.
fn title_track_prefs_row(
row: &rusqlite::Row<'_>,
first: usize,
) -> rusqlite::Result<TitleTrackPrefs> {
Ok(TitleTrackPrefs {
audio_language: row.get(first)?,
subtitle_language: row.get(first + 1)?,
subtitles: row.get::<_, i64>(first + 2)? != 0,
audio_title: row.get(first + 3)?,
subtitle_title: row.get(first + 4)?,
})
}
/// What `season = -1` means in `episode_metadata`: no season at all. /// What `season = -1` means in `episode_metadata`: no season at all.
const ABSOLUTE_SEASON: i64 = -1; const ABSOLUTE_SEASON: i64 = -1;
@@ -227,6 +256,16 @@ impl Catalog {
Ok(catalog) Ok(catalog)
} }
/// How many rows this connection has written since it was opened.
///
/// A cache key, not a statistic: anything derived from the catalog stays
/// valid exactly as long as this does not move. SQLite counts it for us, so
/// no write path has to remember to invalidate anything — which is the
/// failure mode a hand-maintained counter has.
pub fn writes(&self) -> u64 {
self.conn.total_changes()
}
fn migrate(&mut self) -> Result<()> { fn migrate(&mut self) -> Result<()> {
let version: i64 = self let version: i64 = self
.conn .conn
@@ -287,6 +326,12 @@ impl Catalog {
tx.pragma_update(None, "user_version", 7)?; tx.pragma_update(None, "user_version", 7)?;
tx.commit()?; tx.commit()?;
} }
if version < 8 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V8)?;
tx.pragma_update(None, "user_version", 8)?;
tx.commit()?;
}
Ok(()) Ok(())
} }
@@ -500,27 +545,43 @@ impl Catalog {
} }
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> { pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into) self.conn
.query_row(
"SELECT audio_language, subtitle_language, subtitles, audio_title, subtitle_title \
FROM title_track_prefs WHERE title_key=?1",
params![title_key],
|row| title_track_prefs_row(row, 0),
)
.optional()
.map_err(Into::into)
} }
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> { pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?; self.conn.execute(
"INSERT INTO title_track_prefs \
(title_key,audio_language,subtitle_language,subtitles,audio_title,subtitle_title) \
VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(title_key) DO UPDATE SET \
audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,\
subtitles=excluded.subtitles,audio_title=excluded.audio_title,\
subtitle_title=excluded.subtitle_title",
params![
title_key,
prefs.audio_language,
prefs.subtitle_language,
prefs.subtitles as i64,
prefs.audio_title,
prefs.subtitle_title
],
)?;
Ok(()) Ok(())
} }
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> { pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
let mut statement = self.conn.prepare( let mut statement = self.conn.prepare(
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs", "SELECT title_key, audio_language, subtitle_language, subtitles, audio_title, \
subtitle_title FROM title_track_prefs",
)?; )?;
let rows = statement.query_map([], |row| { let rows =
Ok(( statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?;
row.get(0)?,
TitleTrackPrefs {
audio_language: row.get(1)?,
subtitle_language: row.get(2)?,
subtitles: row.get::<_, i64>(3)? != 0,
},
))
})?;
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?) Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
} }
@@ -549,7 +610,9 @@ impl Catalog {
/// Record where playback is. /// Record where playback is.
pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> { pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> {
self.conn.execute( // Cached: playback saves its position every few seconds, and this is
// the one write that runs on a clock rather than on a click.
let mut upsert = self.conn.prepare_cached(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (share_id, link_id) DO UPDATE SET ON CONFLICT (share_id, link_id) DO UPDATE SET
@@ -557,15 +620,15 @@ impl Catalog {
duration_secs = excluded.duration_secs, duration_secs = excluded.duration_secs,
watched = excluded.watched, watched = excluded.watched,
updated_at = excluded.updated_at", updated_at = excluded.updated_at",
params![
share_id,
link_id,
state.position_secs,
state.duration_secs,
state.watched as i64,
state.updated_at,
],
)?; )?;
upsert.execute(params![
share_id,
link_id,
state.position_secs,
state.duration_secs,
state.watched as i64,
state.updated_at,
])?;
Ok(()) Ok(())
} }
@@ -983,7 +1046,7 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
// episode's name rather than the series'. // episode's name rather than the series'.
if row.parsed.episode_title.is_none() if row.parsed.episode_title.is_none()
&& !row.parsed.title.is_empty() && !row.parsed.title.is_empty()
&& row.parsed.title != title && !row.parsed.title.eq_ignore_ascii_case(&title)
{ {
let rest = strip_prefix_title(&row.parsed.title, &title); let rest = strip_prefix_title(&row.parsed.title, &title);
// …unless what is left is a season marker. `Oshi no Ko 3rd // …unless what is left is a season marker. `Oshi no Ko 3rd
@@ -992,7 +1055,11 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
// that is worse than calling none of them anything. // that is worse than calling none of them anything.
if naming::is_season_marker(&rest) { if naming::is_season_marker(&rest) {
row.parsed.season = row.parsed.season.or(naming::parse(&rest).season); row.parsed.season = row.parsed.season.or(naming::parse(&rest).season);
} else { } else if !rest.eq_ignore_ascii_case(&title) {
// Nor when the strip found nothing to remove: a filename
// whose title only *differs* from the folder's — spacing,
// punctuation, a stray article — names the series, not the
// episode, and taking it hands every row the same text.
row.parsed.episode_title = Some(rest); row.parsed.episode_title = Some(rest);
} }
} }
@@ -1076,10 +1143,17 @@ fn ancestry(
/// ///
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called /// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
/// `Neon Genesis Evangelion` should read as `Death & Rebirth`. /// `Neon Genesis Evangelion` should read as `Death & Rebirth`.
///
/// Case-insensitively, because the two names come from different places: the
/// folder is typed by whoever organised the share and the prefix is whatever a
/// release group capitalised. `[Anime Time] Attack On Titan - 38.mkv` under a
/// folder called `Attack on Titan` differs in exactly one letter, and a
/// case-sensitive strip leaves the whole series name behind as the episode's.
fn strip_prefix_title(episode: &str, series: &str) -> String { fn strip_prefix_title(episode: &str, series: &str) -> String {
let stripped = episode let stripped = episode
.strip_prefix(series) .get(..series.len())
.map(|rest| rest.trim_start_matches([' ', '-', '_', '.', ':'])) .filter(|head| head.eq_ignore_ascii_case(series))
.map(|_| episode[series.len()..].trim_start_matches([' ', '-', '_', '.', ':']))
.unwrap_or(episode) .unwrap_or(episode)
.trim(); .trim();
@@ -1370,6 +1444,37 @@ mod tests {
assert_eq!(version, SCHEMA_VERSION); assert_eq!(version, SCHEMA_VERSION);
} }
#[test]
fn a_titles_track_choice_round_trips_with_the_track_names() {
let catalog = Catalog::in_memory().expect("open");
assert_eq!(catalog.title_track_prefs("show").expect("read"), None);
let prefs = TitleTrackPrefs {
audio_language: Some("jpn".into()),
subtitle_language: Some("eng".into()),
subtitles: true,
audio_title: None,
subtitle_title: Some("Full Subtitles".into()),
};
catalog
.set_title_track_prefs("show", &prefs)
.expect("write");
assert_eq!(
catalog.title_track_prefs("show").expect("read"),
Some(prefs.clone())
);
let changed = TitleTrackPrefs {
subtitle_title: Some("Signs & Songs".into()),
..prefs
};
catalog
.set_title_track_prefs("show", &changed)
.expect("overwrite");
let all = catalog.all_title_track_prefs().expect("read all");
assert_eq!(all.get("show"), Some(&changed));
}
/// A newer schema is refused, not downgraded — reinterpreting it would /// A newer schema is refused, not downgraded — reinterpreting it would
/// silently mangle whatever the newer build recorded. /// silently mangle whatever the newer build recorded.
#[test] #[test]
@@ -1518,6 +1623,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1632,6 +1739,47 @@ mod tests {
assert_eq!(episode.parsed.episode_title, None); assert_eq!(episode.parsed.episode_title, None);
} }
/// **What every episode of Attack on Titan was called.** The folder and the
/// release group capitalised the same series two ways, and a case-sensitive
/// prefix strip left the whole series name behind — so all fifty-nine rows
/// read `Attack On Titan`, naming nothing and telling the viewer apart from
/// nothing.
#[test]
fn a_series_name_cased_differently_is_not_the_episodes_name() {
let nodes = vec![
root(),
folder("aot", Some("root"), "Attack on Titan"),
video("e38", "aot", "[Anime Time] Attack On Titan - 38.mkv"),
];
let rows = build_rows("share", &nodes);
let episode = row(&rows, "e38");
assert_eq!(episode.parsed.title, "Attack on Titan", "the folder's");
assert_eq!(episode.parsed.episode, Some(38));
assert_eq!(episode.parsed.episode_title, None);
}
/// The prefix strip is case-insensitive for the same reason: what follows
/// the series name is the episode's, however the group capitalised it.
#[test]
fn a_prefix_cased_differently_is_still_stripped() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
video(
"film",
"eva",
"NEON GENESIS EVANGELION - Death & Rebirth.mkv",
),
];
let rows = build_rows("share", &nodes);
assert_eq!(
row(&rows, "film").parsed.episode_title.as_deref(),
Some("Death & Rebirth"),
);
}
/// The deepest naming folder wins, so films inside a collection folder are /// The deepest naming folder wins, so films inside a collection folder are
/// titled individually rather than by the collection. /// titled individually rather than by the collection.
#[test] #[test]
+483
View File
@@ -0,0 +1,483 @@
//! The chapters a file was muxed with, and what they are for.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
//!
//! Nothing here talks to a player. Reading the chapter list out of mpv is
//! `pstr-player`'s job on the desktop and the JNI adapter's on Android; both
//! hand the result to [`roles`], so a chapter means the same thing on either.
/// One chapter of the file.
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter {
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+10
View File
@@ -73,6 +73,16 @@ impl AppDirs {
Ok(()) Ok(())
} }
/// Settings only the desktop app has: hardware decoding, the cache budget.
pub fn desktop_prefs_file(&self) -> PathBuf {
self.config.join("desktop.json")
}
/// The desktop window's size, for the next launch to open at.
pub fn window_file(&self) -> PathBuf {
self.config.join("window.json")
}
/// The share list. /// The share list.
pub fn shares_file(&self) -> PathBuf { pub fn shares_file(&self) -> PathBuf {
self.config.join("shares.json") self.config.join("shares.json")
+1
View File
@@ -7,6 +7,7 @@
pub mod appearance; pub mod appearance;
pub mod catalog; pub mod catalog;
pub mod chapters;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod library; pub mod library;
+92
View File
@@ -331,6 +331,28 @@ impl Library {
self.titles.is_empty() self.titles.is_empty()
} }
/// Record a new watch state for one file in place. Returns whether the
/// file is in the library.
///
/// What a save during playback or a "mark watched" needs: rebuilding the
/// whole library from the catalog for one changed row re-reads and re-sorts
/// every file, and on a large share that is a visible pause.
pub fn set_watch(&mut self, share_id: &str, link_id: &str, state: WatchState) -> bool {
let episode = self
.titles
.iter_mut()
.flat_map(|title| title.seasons.iter_mut())
.flat_map(|season| season.episodes.iter_mut())
.find(|episode| episode.node.share_id == share_id && episode.node.link_id == link_id);
match episode {
Some(episode) => {
episode.watch = Some(state);
true
}
None => false,
}
}
pub fn get(&self, key: &str) -> Option<&Title> { pub fn get(&self, key: &str) -> Option<&Title> {
self.titles.iter().find(|title| title.key == key) self.titles.iter().find(|title| title.key == key)
} }
@@ -347,6 +369,26 @@ impl Library {
titles titles
} }
/// Every episode that has been started or finished, most recently
/// touched first. The history page.
///
/// An episode marked unwatched keeps a row with position zero, which is
/// "never played" for every purpose here, so it is left out.
pub fn history(&self) -> Vec<(&Title, &Episode)> {
let mut played: Vec<(&Title, &Episode)> = self
.titles
.iter()
.flat_map(|title| title.episodes().map(move |episode| (title, episode)))
.filter(|(_, episode)| {
episode
.watch
.is_some_and(|watch| watch.watched || watch.position_secs > 0.0)
})
.collect();
played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played()));
played
}
/// Substring match over the display name and every filename under it, so /// Substring match over the display name and every filename under it, so
/// searching for a release group or an episode name finds its title. /// searching for a release group or an episode name finds its title.
pub fn search(&self, query: &str) -> Vec<&Title> { pub fn search(&self, query: &str) -> Vec<&Title> {
@@ -495,6 +537,41 @@ mod tests {
} }
} }
#[test]
fn history_is_every_played_episode_newest_first_across_titles() {
let watch_state = HashMap::from([
(
("s".to_string(), "a".to_string()),
watch(600.0, 1400.0, false, 10),
),
(
("s".to_string(), "c".to_string()),
watch(1400.0, 1400.0, true, 30),
),
// Marked unwatched again: a row at zero, which is not a play.
(
("s".to_string(), "b".to_string()),
watch(0.0, 1400.0, false, 40),
),
]);
let library = Library::build(
vec![
node("s", "a", "Show - S01E01", Some(1), Some(1)),
node("s", "b", "Show - S01E02", Some(1), Some(2)),
node("s", "c", "Film", None, None),
node("s", "d", "Other - S01E01", Some(1), Some(1)),
],
&watch_state,
);
let order: Vec<&str> = library
.history()
.iter()
.map(|(_, episode)| episode.node.link_id.as_str())
.collect();
assert_eq!(order, ["c", "a"]);
}
#[test] #[test]
fn groups_episodes_into_seasons() { fn groups_episodes_into_seasons() {
let nodes = vec![ let nodes = vec![
@@ -590,6 +667,21 @@ mod tests {
assert_eq!(library.continue_watching().len(), 1); assert_eq!(library.continue_watching().len(), 1);
} }
#[test]
fn a_watch_state_set_in_place_matches_a_rebuild() {
let nodes = vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
];
let mut states = HashMap::new();
let mut library = Library::build(nodes.clone(), &states);
let state = watch(700.0, 1440.0, false, 20);
assert!(library.set_watch("s", "2", state));
states.insert(("s".into(), "2".into()), state);
assert_eq!(library, Library::build(nodes, &states));
assert!(!library.set_watch("s", "9", state));
}
#[test] #[test]
fn next_up_falls_through_to_the_first_unwatched() { fn next_up_falls_through_to_the_first_unwatched() {
let nodes = vec![ let nodes = vec![
+25
View File
@@ -18,6 +18,11 @@ use crate::error::Result;
/// referenced here because `pstr-core` does not depend on the player. /// referenced here because `pstr-core` does not depend on the player.
const MAX_VOLUME: f64 = 100.0; const MAX_VOLUME: f64 = 100.0;
/// The range of playback rates offered. Past these, audio stops being speech
/// and video stops being watchable, and mpv's own resampler gives up first.
const MIN_SPEED: f64 = 0.25;
const MAX_SPEED: f64 = 4.0;
/// How playback should sound, and in which language. /// How playback should sound, and in which language.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
// A file written by an older version, or hand-edited, is missing fields rather // A file written by an older version, or hand-edited, is missing fields rather
@@ -43,6 +48,18 @@ pub struct PlaybackPrefs {
/// does and what a season of anything is watched like. Off is one checkbox /// does and what a season of anything is watched like. Off is one checkbox
/// away for someone who falls asleep to it. /// away for someone who falls asleep to it.
pub autoplay_next: bool, pub autoplay_next: bool,
/// Whether an opening or ending is seeked past rather than offered.
///
/// Off by default, and deliberately: chapter names are a guess about a file
/// somebody else muxed, and a wrong guess that only greys out a button
/// costs nothing while a wrong guess that seeks costs the scene.
pub auto_skip: bool,
/// How fast playback runs, 1.0 being the file's own rate.
///
/// Kept across episodes deliberately: someone who watches at 1.25 watches
/// everything at 1.25, and a speed that reset every episode would be a
/// setting nobody could use.
pub speed: f64,
} }
impl Default for PlaybackPrefs { impl Default for PlaybackPrefs {
@@ -54,6 +71,8 @@ impl Default for PlaybackPrefs {
subtitle_language: None, subtitle_language: None,
subtitles: true, subtitles: true,
autoplay_next: true, autoplay_next: true,
auto_skip: false,
speed: 1.0,
} }
} }
} }
@@ -68,6 +87,10 @@ impl PlaybackPrefs {
self.volume = MAX_VOLUME; self.volume = MAX_VOLUME;
} }
self.volume = self.volume.clamp(0.0, MAX_VOLUME); self.volume = self.volume.clamp(0.0, MAX_VOLUME);
if !self.speed.is_finite() {
self.speed = 1.0;
}
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty()); self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty()); self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self self
@@ -124,6 +147,8 @@ mod tests {
subtitle_language: Some("eng".into()), subtitle_language: Some("eng".into()),
subtitles: true, subtitles: true,
autoplay_next: false, autoplay_next: false,
auto_skip: true,
speed: 1.5,
}; };
save(&dirs, &prefs).unwrap(); save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs); assert_eq!(load(&dirs).unwrap(), prefs);
+252 -18
View File
@@ -160,6 +160,49 @@ impl ShareStore {
Ok(share) Ok(share)
} }
/// Re-supply a configured share's secrets, keeping everything else.
///
/// A secret store can lose the ability to read what it wrote: an Android
/// Keystore key is invalidated by a lockscreen change or a device restore,
/// and a desktop keyring can be reset. After that every open of the share
/// fails — and so, often, does removing it, because removal deletes a
/// secret the store can no longer touch. Re-entering the link is the way
/// back that does not cost the catalog and the offline files.
///
/// The link must be the same one: a different token is a different share,
/// and quietly repointing an existing library at it would leave every
/// catalog row describing files that are no longer there.
pub fn replace_secrets(
&self,
id: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<Share> {
let token = token_from_url(url)?;
let mut shares = self.list()?;
let share = shares
.iter_mut()
.find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.token != token {
return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(),
));
}
let secrets = ShareSecrets {
url: url.to_string(),
custom_password: custom_password
.filter(|password| !password.is_empty())
.map(str::to_string),
};
self.store_secrets(id, &secrets)?;
share.has_custom_password = secrets.custom_password.is_some();
let updated = share.clone();
write_json(&self.dirs.shares_file(), &shares)?;
Ok(updated)
}
/// Forget a share and its secrets. /// Forget a share and its secrets.
/// ///
/// The config entry goes first here, for the mirror-image reason: if the /// The config entry goes first here, for the mirror-image reason: if the
@@ -223,10 +266,28 @@ impl SharedLibrary {
/// the whole library — one revoked link should not make the other three /// the whole library — one revoked link should not make the other three
/// unwatchable. /// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> { pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new()).await
}
/// Open every configured share, reusing the clients in `reusable`.
///
/// Adding or removing one link should not cost a fresh handshake for the
/// shares that were already open, so a caller that still holds them can
/// hand them back here. A share whose stored secrets changed must *not*
/// appear in `reusable` — its old client authenticates against the old
/// link.
pub async fn open_all_reusing(
store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new(); let mut clients = BTreeMap::new();
let mut failures = Vec::new(); let mut failures = Vec::new();
for share in store.list()? { for share in store.list()? {
if let Some(client) = reusable.remove(&share.id) {
clients.insert(share.id, client);
continue;
}
match store.open(&share).await { match store.open(&share).await {
Ok(client) => { Ok(client) => {
clients.insert(share.id.clone(), client); clients.insert(share.id.clone(), client);
@@ -238,6 +299,23 @@ impl SharedLibrary {
Ok((Self { clients }, failures)) Ok((Self { clients }, failures))
} }
/// The opened clients, minus `dropping`, ready to hand to
/// [`SharedLibrary::open_all_reusing`].
///
/// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale.
pub fn reusable_clients(
&self,
dropping: &str,
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
self.clients
.iter()
.filter(|(id, _)| id.as_str() != dropping)
.map(|(id, client)| (id.clone(), client.clone()))
.collect()
}
/// The client for one share. /// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> { pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
self.clients.get(share_id) self.clients.get(share_id)
@@ -259,43 +337,91 @@ impl SharedLibrary {
Ok(()) Ok(())
} }
/// Walk one share's whole subtree, depth first, yielding every node. /// Walk one share's whole subtree, level by level, yielding every node.
/// ///
/// There is no recursion helper on the visitor path — the authenticated /// There is no recursion helper on the visitor path — the authenticated
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart /// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
/// there — so the walk is here. /// there — so the walk is here.
///
/// **Breadth first and fanned out, because a media share is wide.** The
/// shape of one is a root holding a folder per series, each holding a folder
/// per season: a couple of hundred folders that are each one listing request
/// plus a detail fetch, and almost all of them siblings. Walking that one
/// folder at a time spends the whole crawl waiting on a round trip it could
/// have overlapped — the crawl was minutes of latency and seconds of work.
/// Taking a whole level at once is what collapses it.
///
/// The client is cheap to clone and its clones share one node-key cache and
/// one single-flight map, so siblings that need the same ancestor key wait
/// on one derivation rather than each starting their own. `buffered` rather
/// than `buffer_unordered`: the crawl's output order is otherwise a race,
/// and a catalog that reorders itself between two identical crawls is
/// needlessly hard to diff.
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> { pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
self.crawl_reporting(share_id, |_| {}).await
}
/// [`Self::crawl`], calling `progress` with how many nodes have been found
/// so far each time a folder's listing comes back.
///
/// A crawl of a large share takes minutes, and "crawling…" for minutes
/// reads as stuck. A count that keeps rising does not.
pub async fn crawl_reporting(
&self,
share_id: &str,
progress: impl Fn(usize),
) -> Result<Vec<Node>> {
use futures::stream::{StreamExt as _, TryStreamExt as _};
let client = self let client = self
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?; let root = client.get_root_node().await?;
let mut found = Vec::new(); let mut level = vec![root.uid.clone()];
let mut folders = vec![root.uid.clone()]; let mut found = vec![root];
found.push(root);
while let Some(folder) = folders.pop() { while !level.is_empty() {
let child_uids: Vec<NodeUid> = let listings = level.into_iter().map(|folder| {
client.enumerate_folder_children_node_uids(&folder).await?; let client = client.clone();
if child_uids.is_empty() { async move {
continue; let child_uids: Vec<NodeUid> =
} client.enumerate_folder_children_node_uids(&folder).await?;
// Chunked and fanned out inside the SDK, and the node keys it if child_uids.is_empty() {
// unlocks along the way are cached there, so a deep tree does not return Ok(Vec::new());
// re-derive the same ancestors per child. }
let children = client.enumerate_nodes(&child_uids).await?; // Chunked and fanned out inside the SDK too, so this is a
for child in children { // multiplier on that concurrency rather than the only one.
if child.is_folder() { client.enumerate_nodes(&child_uids).await
folders.push(child.uid.clone());
} }
found.push(child); });
let mut listings = futures::stream::iter(listings).buffered(CRAWL_CONCURRENCY);
let mut next = Vec::new();
while let Some(children) = listings.try_next().await? {
for child in children {
if child.is_folder() {
next.push(child.uid.clone());
}
found.push(child);
}
progress(found.len());
} }
level = next;
} }
Ok(found) Ok(found)
} }
} }
/// How many folders of one level to list at once.
///
/// Each of these fans out again inside the SDK — up to four detail batches per
/// folder — so this is not the number of requests in flight but a multiplier on
/// it. Six keeps the ceiling in the low tens: enough to hide the round trip on
/// a wide level, well short of a burst a share server would read as abuse.
const CRAWL_CONCURRENCY: usize = 6;
/// The app-version string Proton identifies this client by. /// The app-version string Proton identifies this client by.
/// ///
/// The `external-drive-` prefix is **not** decoration: the API parses the part /// The `external-drive-` prefix is **not** decoration: the API parses the part
@@ -316,6 +442,15 @@ fn client_configuration() -> ProtonClientConfiguration {
/// Parsed here as well as in the SDK because the store needs a stable id for a /// Parsed here as well as in the SDK because the store needs a stable id for a
/// share *before* it has opened it — and the id must not be derived from the /// share *before* it has opened it — and the id must not be derived from the
/// password, which would put a secret in the config file by the back door. /// password, which would put a secret in the config file by the back door.
/// The share token a link names, or why it is not a usable share link.
///
/// Public so a form can say what is wrong with a link while it is still being
/// pasted, rather than after a round trip through [`ShareStore::add`] — which
/// asks exactly this first.
pub fn share_token(url: &str) -> Result<String> {
token_from_url(url)
}
fn token_from_url(url: &str) -> Result<String> { fn token_from_url(url: &str) -> Result<String> {
let (_, tail) = url let (_, tail) = url
.split_once("/urls/") .split_once("/urls/")
@@ -396,6 +531,105 @@ mod tests {
} }
} }
/// A store that has forgotten how to read what it wrote, which is what an
/// invalidated Android Keystore key looks like from Rust.
#[derive(Default)]
struct Unreadable {
entries: std::sync::Mutex<BTreeMap<String, String>>,
readable: std::sync::atomic::AtomicBool,
}
impl SecretStore for Unreadable {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.entries
.lock()
.unwrap()
.insert(key.to_owned(), value.to_owned());
self.readable
.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
if !self.readable.load(std::sync::atomic::Ordering::Relaxed) {
return Err(Error::Config("key permanently invalidated".into()));
}
Ok(self.entries.lock().unwrap().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.entries.lock().unwrap().remove(key);
Ok(())
}
}
fn store_in(name: &str) -> (ShareStore, Arc<Unreadable>) {
let root = std::env::temp_dir().join(format!("pstr-shares-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dirs = AppDirs::from_paths(root.join("config"), root.join("data"), root.join("cache"))
.expect("app directories");
let secrets = Arc::new(Unreadable::default());
(
ShareStore::with_secret_store(dirs, secrets.clone()),
secrets,
)
}
/// The way back from an unreadable secret store. The share keeps its id, so
/// the catalog rows and offline files keyed by it stay valid — which is the
/// whole reason this exists rather than Remove-and-re-add.
#[test]
fn a_re_entered_link_restores_a_share_whose_secret_cannot_be_read() {
let (store, secrets) = store_in("repair");
let url = "https://drive.proton.me/urls/ABC123#s3cr3t";
let added = store.add("Library", url, None).expect("add");
secrets
.readable
.store(false, std::sync::atomic::Ordering::Relaxed);
assert!(store.load_secrets(&added.id).is_err());
let repaired = store
.replace_secrets(&added.id, url, Some("new-password"))
.expect("replace");
assert_eq!(repaired.id, added.id);
assert!(repaired.has_custom_password);
assert!(
store
.list()
.expect("list")
.iter()
.any(|share| share.id == added.id && share.has_custom_password)
);
}
/// A different link is a different share. Repointing this one at it would
/// leave every catalog row and offline file describing files that are not
/// there.
#[test]
fn a_re_entered_link_for_a_different_share_is_refused() {
let (store, _) = store_in("repair-mismatch");
let added = store
.add(
"Library",
"https://drive.proton.me/urls/ABC123#s3cr3t",
None,
)
.expect("add");
let error = store
.replace_secrets(
&added.id,
"https://drive.proton.me/urls/DEF456#s3cr3t",
None,
)
.expect_err("a different token must be refused");
assert!(
error.to_string().contains("different share"),
"says what is wrong: {error}"
);
}
/// The id must be derivable from the token alone — deriving it from the URL /// The id must be derivable from the token alone — deriving it from the URL
/// would mix a secret into the config file. /// would mix a secret into the config file.
#[test] #[test]
+3 -1
View File
@@ -10,6 +10,8 @@ authors.workspace = true
[dependencies] [dependencies]
pstr-core.workspace = true pstr-core.workspace = true
# `time` for the rate limiter's sleeps, `sync` for the mutex it paces behind.
tokio = { workspace = true, features = ["time", "sync"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
@@ -19,4 +21,4 @@ tracing.workspace = true
reqwest.workspace = true reqwest.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
+55 -15
View File
@@ -14,11 +14,30 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize; use serde::Deserialize;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query}; use crate::matching::{Candidate, Query};
use crate::provider::Provider; use crate::provider::Provider;
const ENDPOINT: &str = "https://graphql.anilist.co"; const ENDPOINT: &str = "https://graphql.anilist.co";
/// What AniList documents as its ceiling: ninety requests a minute.
///
/// A starting point, not a belief — the API has been running in a degraded mode
/// that allows thirty for a long time now, and says so in `X-RateLimit-Limit` on
/// every answer. The limiter retunes itself off that header after the first
/// request, so the only cost of starting optimistic is one `429` on a run that
/// begins during a degraded window.
const REQUESTS_PER_MINUTE: u32 = 90;
/// How many times to sit out a `429` before giving up on one request.
///
/// Two, because the pause it asks for is the rest of its rate-limit window and
/// the limiter holds every other lookup for the same stretch — a third round is
/// minutes of a scan spent waiting rather than matching. Giving up returns
/// [`Error::RateLimited`], which is a failure and therefore not cached: the
/// title stays askable and the next run picks it up.
const RETRIES: usize = 2;
/// How many answers to score. AniList's own relevance ordering is good, and the /// How many answers to score. AniList's own relevance ordering is good, and the
/// right title is essentially always in the first handful; asking for more costs /// right title is essentially always in the first handful; asking for more costs
/// them bandwidth and buys nothing. /// them bandwidth and buys nothing.
@@ -66,11 +85,16 @@ query ($id: Int) {
pub struct AniList { pub struct AniList {
http: reqwest::Client, http: reqwest::Client,
/// Shared by every lookup in flight — see [`crate::limiter`].
limiter: RateLimiter,
} }
impl AniList { impl AniList {
pub fn new(http: reqwest::Client) -> Self { pub fn new(http: reqwest::Client) -> Self {
Self { http } Self {
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
} }
} }
@@ -132,24 +156,40 @@ impl Provider for AniList {
} }
impl AniList { impl AniList {
/// One GraphQL request, with AniList's two ways of failing — an HTTP status /// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
/// and an `errors` array under a 200 — folded into one. /// status and an `errors` array under a 200 — folded into one.
///
/// A `429` is waited out rather than returned: a scan sends hundreds of
/// these, and the request that trips the limit is not the one that was
/// wrong. See [`crate::limiter`].
async fn query<T: serde::de::DeserializeOwned>( async fn query<T: serde::de::DeserializeOwned>(
&self, &self,
body: serde_json::Value, body: serde_json::Value,
) -> Result<Option<T>> { ) -> Result<Option<T>> {
let response = self.http.post(ENDPOINT).json(&body).send().await?; let mut attempts = 0usize;
let status = response.status(); let response = loop {
if !status.is_success() { self.limiter.acquire().await;
// 429 is the one worth naming: AniList rate-limits by the minute, let response = self.http.post(ENDPOINT).json(&body).send().await?;
// and a caller that knows it was throttled can back off rather than let status = response.status();
// cache a miss for a title that would have matched.
return Err(if status.as_u16() == 429 { if status.as_u16() == 429 {
Error::RateLimited // The pause holds every other lookup too, so the rest of the
} else { // run does not spend the window earning more 429s.
Error::Http(format!("AniList answered {status}")) let waited = self.limiter.throttled(response.headers()).await;
}); attempts += 1;
} if attempts > RETRIES {
return Err(Error::RateLimited);
}
tracing::debug!("AniList is throttling; waiting {waited:?}");
continue;
}
self.limiter.observe(response.headers()).await;
if !status.is_success() {
return Err(Error::Http(format!("AniList answered {status}")));
}
break response;
};
let payload: Response<T> = response.json().await?; let payload: Response<T> = response.json().await?;
if let Some(errors) = payload.errors if let Some(errors) = payload.errors
+1
View File
@@ -29,6 +29,7 @@
pub mod anilist; pub mod anilist;
pub mod error; pub mod error;
pub mod limiter;
pub mod matching; pub mod matching;
pub mod provider; pub mod provider;
pub mod service; pub mod service;
+227
View File
@@ -0,0 +1,227 @@
//! Spacing requests out, so a provider does not have to.
//!
//! Scanning a new library is the one moment this app talks to a metadata
//! provider in bulk: every title is one search, a matched series is one more
//! request per season, and a hundred-folder share is several hundred requests
//! in a burst. AniList allows ninety a minute — thirty while its API is in the
//! degraded mode it has been in for a while — and answers everything past that
//! with a `429`. Without pacing, the first thirty titles match and the rest of
//! the run fails; because a failure is deliberately not cached (see
//! [`crate::service`]), the next scan starts over and fails in the same place.
//!
//! So requests take a slot here first. The rate is not a constant but the
//! provider's own: `X-RateLimit-Limit` on any answer retunes it, and a `429`
//! parks every caller for as long as `Retry-After` asks. One limiter is shared
//! by every lookup in flight — it lives in the provider, the provider lives in
//! an `Arc` inside [`crate::MetadataService`], and cloning the service to hand
//! it to a worker shares the limiter rather than copying it.
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time::Instant;
/// Never faster than this, whatever a header claims.
const MIN_INTERVAL: Duration = Duration::from_millis(20);
/// Never slower than this. A provider that asks for a longer pause gets it via
/// [`RateLimiter::throttled`]; this is only the floor the steady rate decays to,
/// so a misread header cannot wedge a scan forever.
const MAX_INTERVAL: Duration = Duration::from_secs(5);
/// How long to honour a `429` that came with no `Retry-After`.
const DEFAULT_BACKOFF: Duration = Duration::from_secs(60);
/// The longest a single throttle may park requests for.
///
/// A scan is something a person is watching happen. Two minutes of silence is
/// worse than a partial answer they can ask to complete later, and the titles
/// that did not get through stay unmatched and askable.
const MAX_BACKOFF: Duration = Duration::from_secs(30);
/// A shared, self-tuning pacer for one provider's API.
pub struct RateLimiter {
state: Mutex<State>,
}
struct State {
/// The earliest a request may be sent.
next: Instant,
/// The spacing between requests.
interval: Duration,
}
impl RateLimiter {
/// A limiter starting at `per_minute` requests a minute.
///
/// A starting point only: the first answer that carries a limit header
/// replaces it with what the provider actually allows right now.
pub fn per_minute(per_minute: u32) -> Self {
Self::new(interval_for(per_minute.max(1)))
}
fn new(interval: Duration) -> Self {
Self {
state: Mutex::new(State {
next: Instant::now(),
interval,
}),
}
}
/// Wait until a request may be sent, then claim that slot.
///
/// Re-checks after every sleep rather than reserving a slot up front, so a
/// [`throttled`](Self::throttled) that lands while callers are already
/// waiting holds them too — which is the whole point of a shared limiter
/// during a burst.
pub async fn acquire(&self) {
loop {
let wait = {
let mut state = self.state.lock().await;
let now = Instant::now();
if state.next <= now {
state.next = now + state.interval;
return;
}
state.next - now
};
tokio::time::sleep(wait).await;
}
}
/// Retune from a provider's own rate-limit headers.
pub async fn observe(&self, headers: &reqwest::header::HeaderMap) {
let Some(limit) = header_number(headers, "x-ratelimit-limit") else {
return;
};
let interval = interval_for(limit.max(1) as u32);
let mut state = self.state.lock().await;
if state.interval != interval {
tracing::debug!("provider allows {limit}/min; pacing at {interval:?}");
state.interval = interval;
}
}
/// Park every caller after a `429`.
///
/// `Retry-After` is seconds, and is the provider telling us exactly how
/// long its window has left to run — a shorter guess would just earn
/// another `429`.
pub async fn throttled(&self, headers: &reqwest::header::HeaderMap) -> Duration {
let wait = header_number(headers, "retry-after")
.map(|seconds| Duration::from_secs(seconds.max(1)))
.unwrap_or(DEFAULT_BACKOFF)
.min(MAX_BACKOFF);
let mut state = self.state.lock().await;
let until = Instant::now() + wait;
if until > state.next {
state.next = until;
}
wait
}
}
/// `90` a minute → one every 667ms.
fn interval_for(per_minute: u32) -> Duration {
Duration::from_secs_f64(60.0 / f64::from(per_minute)).clamp(MIN_INTERVAL, MAX_INTERVAL)
}
fn header_number(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn headers(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
let mut map = reqwest::header::HeaderMap::new();
for (name, value) in pairs {
map.insert(
reqwest::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"),
value.parse().expect("header value"),
);
}
map
}
#[test]
fn a_rate_becomes_the_spacing_it_implies() {
assert_eq!(interval_for(60), Duration::from_secs(1));
assert_eq!(interval_for(30), Duration::from_secs(2));
// Clamped at both ends: a provider claiming one request a minute must
// not stall a scan for a minute per title, and one claiming a million
// still gets paced.
assert_eq!(interval_for(1), MAX_INTERVAL);
assert_eq!(interval_for(1_000_000), MIN_INTERVAL);
}
#[tokio::test]
async fn the_first_request_of_a_run_does_not_wait() {
let limiter = RateLimiter::per_minute(30);
let started = Instant::now();
limiter.acquire().await;
assert!(started.elapsed() < Duration::from_millis(50));
}
/// The one that matters: two requests in a row are spaced, not sent
/// together.
#[tokio::test(start_paused = true)]
async fn a_second_request_waits_out_the_interval() {
let limiter = RateLimiter::per_minute(60);
limiter.acquire().await;
let started = Instant::now();
limiter.acquire().await;
assert!(started.elapsed() >= Duration::from_secs(1));
}
#[tokio::test(start_paused = true)]
async fn a_429_parks_the_next_request_for_as_long_as_it_asked() {
let limiter = RateLimiter::per_minute(600);
let waited = limiter.throttled(&headers(&[("retry-after", "12")])).await;
assert_eq!(waited, Duration::from_secs(12));
let started = Instant::now();
limiter.acquire().await;
assert!(started.elapsed() >= Duration::from_secs(12));
}
/// A throttle with no header still has to park for something, and a
/// ridiculous one is capped — a scan is something a person is watching.
#[tokio::test]
async fn a_429_without_a_header_backs_off_by_a_capped_default() {
let limiter = RateLimiter::per_minute(90);
assert_eq!(
limiter.throttled(&headers(&[])).await,
DEFAULT_BACKOFF.min(MAX_BACKOFF)
);
assert_eq!(
limiter
.throttled(&headers(&[("retry-after", "86400")]))
.await,
MAX_BACKOFF
);
}
#[tokio::test]
async fn the_provider_s_own_limit_retunes_the_pacing() {
let limiter = RateLimiter::per_minute(90);
limiter
.observe(&headers(&[("x-ratelimit-limit", "30")]))
.await;
assert_eq!(
limiter.state.lock().await.interval,
Duration::from_secs(2),
"AniList's degraded mode allows 30/min and says so on every answer"
);
// Nonsense, and an absent header, leave the pacing alone.
limiter
.observe(&headers(&[("x-ratelimit-limit", "soon")]))
.await;
limiter.observe(&headers(&[])).await;
assert_eq!(limiter.state.lock().await.interval, Duration::from_secs(2));
}
}
+48
View File
@@ -223,6 +223,54 @@ impl MetadataService {
Ok(episodes) Ok(episodes)
} }
/// The episode list for a whole title, with a failure treated as an empty
/// one.
///
/// Deliberately not fatal to the title it belongs to: a poster and a
/// synopsis that arrived are worth keeping even when the episode request
/// was the one that hit the rate limit. The empty result is not cached as
/// an answer — the next match run asks again — because the only thing that
/// triggers one is a viewer pressing the button.
///
/// The match itself only ever answers for *one* entry, and on a provider
/// that files each sequel separately — AniList — that entry is season one.
/// So each further season of the title is searched for by name and its
/// episodes are tagged with the season they came from; without that,
/// seasons two and three have no episode names at all. See
/// [`MetadataService::season_episodes`].
pub async fn title_episodes(
&self,
title: &Title,
found: &TitleMetadata,
) -> Vec<EpisodeMetadata> {
let mut episodes = self
.episodes(found)
.await
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
.unwrap_or_default();
if !self.splits_seasons() {
return episodes;
}
// Season one is what the title's own match already answered for.
let later: Vec<u32> = title
.seasons
.iter()
.filter_map(|season| season.number)
.filter(|number| *number > 1)
.collect();
for season in later {
match self.season_episodes(title, season).await {
Ok(found) => episodes.extend(found),
Err(error) => {
tracing::warn!("episodes for {} season {season}: {error}", title.name);
}
}
}
episodes
}
/// Whether this provider files a sequel as its own entry, so that a title /// Whether this provider files a sequel as its own entry, so that a title
/// with several seasons has to be searched for once per season. /// with several seasons has to be searched for once per season.
pub fn splits_seasons(&self) -> bool { pub fn splits_seasons(&self) -> bool {
+55 -38
View File
@@ -15,6 +15,7 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize; use serde::Deserialize;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query}; use crate::matching::{Candidate, Query};
use crate::provider::Provider; use crate::provider::Provider;
@@ -37,13 +38,23 @@ const STILL_SIZE: &str = "w300";
/// at here, give or take; `original` is several megabytes of print-resolution /// at here, give or take; `original` is several megabytes of print-resolution
/// artwork per title, which is bandwidth spent on pixels no tile can show. /// artwork per title, which is bandwidth spent on pixels no tile can show.
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
const POSTER_SIZE: &str = "w500"; const POSTER_SIZE: &str = "w500";
const BACKDROP_SIZE: &str = "w780"; const BACKDROP_SIZE: &str = "w780";
/// TMDB's published ceiling is around fifty requests a second and it no longer
/// enforces a daily one. This is well under it: the point of pacing here is a
/// burst that would look like abuse, not a rate anyone is close to.
const REQUESTS_PER_MINUTE: u32 = 600;
/// As [`crate::anilist`]'s.
const RETRIES: usize = 2;
pub struct Tmdb { pub struct Tmdb {
http: reqwest::Client, http: reqwest::Client,
api_key: String, api_key: String,
language: String, language: String,
limiter: RateLimiter,
} }
impl Tmdb { impl Tmdb {
@@ -52,6 +63,7 @@ impl Tmdb {
http, http,
api_key, api_key,
language, language,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
} }
} }
} }
@@ -72,28 +84,12 @@ impl Provider for Tmdb {
return Err(Error::MissingApiKey(ProviderId::Tmdb)); return Err(Error::MissingApiKey(ProviderId::Tmdb));
} }
let response = self let payload: SearchResponse = self
.http .get(
.get(ENDPOINT) ENDPOINT,
.query(&[ &[("query", query.name.as_str()), ("include_adult", "false")],
("api_key", self.api_key.as_str()), )
("query", query.name.as_str()),
("language", self.language.as_str()),
("include_adult", "false"),
])
.send()
.await?; .await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 => Error::MissingApiKey(ProviderId::Tmdb),
429 => Error::RateLimited,
_ => Error::Http(format!("TMDB answered {status}")),
});
}
let payload: SearchResponse = response.json().await?;
Ok(payload Ok(payload
.results .results
.into_iter() .into_iter()
@@ -172,7 +168,11 @@ impl Provider for Tmdb {
} }
impl Tmdb { impl Tmdb {
/// One authenticated GET, with TMDB's failures named. /// One authenticated GET, paced, with TMDB's failures named.
///
/// TMDB is far more generous than AniList and a library-sized run rarely
/// troubles it, but the pacing costs nothing here and the `429` handling is
/// the same code — see [`crate::limiter`].
async fn get<T: serde::de::DeserializeOwned>( async fn get<T: serde::de::DeserializeOwned>(
&self, &self,
url: &str, url: &str,
@@ -181,23 +181,40 @@ impl Tmdb {
if self.api_key.trim().is_empty() { if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb)); return Err(Error::MissingApiKey(ProviderId::Tmdb));
} }
let mut request = self.http.get(url).query(&[
("api_key", self.api_key.as_str()),
("language", self.language.as_str()),
]);
for pair in extra {
request = request.query(&[pair]);
}
let response = request.send().await?; let mut attempts = 0usize;
let status = response.status(); let response = loop {
if !status.is_success() { let mut request = self.http.get(url).query(&[
return Err(match status.as_u16() { ("api_key", self.api_key.as_str()),
401 => Error::MissingApiKey(ProviderId::Tmdb), ("language", self.language.as_str()),
429 => Error::RateLimited, ]);
_ => Error::Http(format!("TMDB answered {status}")), for pair in extra {
}); request = request.query(&[pair]);
} }
self.limiter.acquire().await;
let response = request.send().await?;
let status = response.status();
if status.as_u16() == 429 {
let waited = self.limiter.throttled(response.headers()).await;
attempts += 1;
if attempts > RETRIES {
return Err(Error::RateLimited);
}
tracing::debug!("TMDB is throttling; waiting {waited:?}");
continue;
}
if !status.is_success() {
return Err(match status.as_u16() {
401 => Error::MissingApiKey(ProviderId::Tmdb),
_ => Error::Http(format!("TMDB answered {status}")),
});
}
break response;
};
Ok(response.json().await?) Ok(response.json().await?)
} }
} }
+3
View File
@@ -9,6 +9,9 @@ rust-version.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
# For `chapters`, which Android reaches through its own bridge and so cannot
# live here. Already in the tree via pstr-stream.
pstr-core.workspace = true
pstr-stream.workspace = true pstr-stream.workspace = true
tokio.workspace = true tokio.workspace = true
thiserror.workspace = true thiserror.workspace = true
+9 -478
View File
@@ -1,280 +1,19 @@
//! The chapters a file was muxed with, and what they are for. //! Reading the chapter list out of the player.
//!
//! The chapters themselves — what a name claims, what the rest of the file
//! settles, where the tail begins — live in [`pstr_core::chapters`], because
//! Android reads its chapter list through its own JNI adapter and must reach
//! the same verdict as this crate does. Only the mpv read is here.
//! //!
//! Read the same way as the track list — `chapter-list/N/title`, one //! Read the same way as the track list — `chapter-list/N/title`, one
//! sub-property at a time — for the same reason: those are plain strings and //! sub-property at a time — for the same reason: those are plain strings and
//! doubles, and a node walk would buy nothing. //! doubles, and a node walk would buy nothing.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
use libmpv2::Mpv; use libmpv2::Mpv;
/// One chapter of the file. pub use pstr_core::chapters::{
#[derive(Debug, Clone, PartialEq)] Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles,
pub struct Chapter { };
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
/// Every chapter of the file mpv currently has open. /// Every chapter of the file mpv currently has open.
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> { pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
@@ -293,211 +32,3 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
}) })
.collect() .collect()
} }
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+1 -1
View File
@@ -52,4 +52,4 @@ pub use player::{
}; };
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
pub use render::VideoRenderer; pub use render::VideoRenderer;
pub use tracks::{Track, TrackKind, language_name}; pub use tracks::{Track, TrackKind, language_name, pick_track};
+37
View File
@@ -94,6 +94,11 @@ pub enum PlayerEvent {
/// Output volume, 0–100. /// Output volume, 0–100.
Volume(f64), Volume(f64),
Muted(bool), Muted(bool),
/// How far into the file the demuxer has read ahead, in seconds from the
/// start — the end of what can play without another fetch.
Buffered(f64),
/// Whether playback is held waiting for data.
Buffering(bool),
/// What the file contains and what is playing of it. /// What the file contains and what is playing of it.
/// ///
/// Not an mpv event: mpv reports a track list as a node property, and this /// Not an mpv event: mpv reports a track list as a node property, and this
@@ -308,6 +313,10 @@ impl Player {
// it clamps a restored value to. // it clamps a restored value to.
self.mpv.observe_property("volume", Format::Double, 0)?; self.mpv.observe_property("volume", Format::Double, 0)?;
self.mpv.observe_property("mute", Format::Flag, 0)?; self.mpv.observe_property("mute", Format::Flag, 0)?;
self.mpv
.observe_property("demuxer-cache-time", Format::Double, 0)?;
self.mpv
.observe_property("paused-for-cache", Format::Flag, 0)?;
Ok(()) Ok(())
} }
@@ -449,6 +458,32 @@ impl Player {
Ok(()) Ok(())
} }
/// Where subtitles sit, as a percentage of the window's height from the
/// top: 100 is mpv's usual place along the bottom edge.
///
/// `sub-pos` rather than a margin, because it moves styled (ASS) subtitles
/// too — and those are most of what an anime library carries.
pub fn set_subtitle_position(&self, percent: f64) -> Result<()> {
self.mpv
.set_property("sub-pos", percent.clamp(0.0, 150.0).round() as i64)?;
Ok(())
}
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
/// default and what keeps a voice a voice at 1.5×.
pub fn set_speed(&self, speed: f64) -> Result<()> {
self.mpv.set_property("speed", speed.clamp(0.25, 4.0))?;
Ok(())
}
/// Save the current frame, without subtitles or anything drawn over it,
/// to `path`. The format follows the extension.
pub fn screenshot(&self, path: &std::path::Path) -> Result<()> {
let path = path.to_string_lossy();
self.mpv.command("screenshot-to-file", &[&path, "video"])?;
Ok(())
}
pub fn stop(&self) -> Result<()> { pub fn stop(&self) -> Result<()> {
self.mpv.command("stop", &[])?; self.mpv.command("stop", &[])?;
Ok(()) Ok(())
@@ -505,6 +540,8 @@ fn translate(event: Event<'_>) -> PlayerEvent {
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value), ("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value), ("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value), ("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value),
("demuxer-cache-time", PropertyData::Double(value)) => PlayerEvent::Buffered(value),
("paused-for-cache", PropertyData::Flag(value)) => PlayerEvent::Buffering(value),
_ => PlayerEvent::Other, _ => PlayerEvent::Other,
}, },
_ => PlayerEvent::Other, _ => PlayerEvent::Other,
+131
View File
@@ -107,6 +107,56 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Track> {
.collect() .collect()
} }
/// The track of `kind` that matches a choice remembered from another file.
///
/// A title outranks a language: two English subtitle tracks — "Signs & Songs"
/// and "Full Subtitles" — share a language and differ only by name, and the
/// name is what was picked. A title in the wrong language is not a match, since
/// "Full Subtitles" in German is not what someone reading English chose. Without
/// a title match, a track in the language wins, the container's default first.
/// `None` when nothing matches, which leaves the current selection standing.
pub fn pick_track(
tracks: &[Track],
kind: TrackKind,
language: Option<&str>,
title: Option<&str>,
) -> Option<i64> {
let in_language = |track: &Track| match (language, track.language.as_deref()) {
(None, _) => true,
(Some(wanted), Some(have)) => same_language(wanted, have),
(Some(_), None) => false,
};
let of_kind = || tracks.iter().filter(move |track| track.kind == kind);
if let Some(title) = title
&& let Some(track) = of_kind().find(|track| {
in_language(track)
&& track
.title
.as_deref()
.is_some_and(|have| have.eq_ignore_ascii_case(title))
})
{
return Some(track.id);
}
language?;
let mut matching = of_kind().filter(|track| in_language(track));
let first = matching.next()?;
Some(
std::iter::once(first)
.chain(matching)
.find(|track| track.default)
.unwrap_or(first)
.id,
)
}
/// Whether two container tags name the same language: "en" and "eng" do.
fn same_language(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
}
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> { fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?; let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
let id: i64 = mpv let id: i64 = mpv
@@ -242,6 +292,87 @@ mod tests {
assert_eq!(language_name("mis"), "mis"); assert_eq!(language_name("mis"), "mis");
} }
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
Track {
language: language.map(Into::into),
title: title.map(Into::into),
..track(kind, id)
}
}
#[test]
fn a_remembered_title_picks_between_tracks_of_one_language() {
let tracks = [
named(TrackKind::Subtitle, 1, Some("eng"), Some("Signs & Songs")),
named(TrackKind::Subtitle, 2, Some("eng"), Some("Full Subtitles")),
];
let mut signs = tracks[0].clone();
signs.default = true;
let tracks = [signs, tracks[1].clone()];
assert_eq!(
pick_track(
&tracks,
TrackKind::Subtitle,
Some("eng"),
Some("full subtitles")
),
Some(2)
);
}
#[test]
fn a_title_in_another_language_is_not_the_one_that_was_chosen() {
let tracks = [
named(TrackKind::Subtitle, 1, Some("ger"), Some("Full Subtitles")),
named(TrackKind::Subtitle, 2, Some("en"), Some("Dialogue")),
];
// Falls back to the language, and "en" is the same language as "eng".
assert_eq!(
pick_track(
&tracks,
TrackKind::Subtitle,
Some("eng"),
Some("Full Subtitles")
),
Some(2)
);
}
#[test]
fn an_untagged_track_is_found_by_its_title_alone() {
let tracks = [
named(TrackKind::Audio, 1, None, Some("Stereo")),
named(TrackKind::Audio, 2, None, Some("Commentary")),
];
assert_eq!(
pick_track(&tracks, TrackKind::Audio, None, Some("Commentary")),
Some(2)
);
}
#[test]
fn a_language_match_prefers_the_default_track_and_nothing_matching_is_none() {
let mut second = named(TrackKind::Audio, 2, Some("jpn"), None);
second.default = true;
let tracks = [
named(TrackKind::Audio, 1, Some("jpn"), Some("Commentary")),
second,
named(TrackKind::Subtitle, 3, Some("jpn"), None),
];
assert_eq!(
pick_track(&tracks, TrackKind::Audio, Some("ja"), None),
Some(2)
);
assert_eq!(
pick_track(&tracks, TrackKind::Audio, Some("fre"), None),
None
);
assert_eq!(
pick_track(&tracks, TrackKind::Audio, None, Some("Stereo")),
None
);
}
#[test] #[test]
fn only_the_kinds_the_player_can_select_are_tracks() { fn only_the_kinds_the_player_can_select_are_tracks() {
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio)); assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more