Compare commits

32 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
72 changed files with 10030 additions and 1391 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
+251 -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.4.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.4.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.4.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.4.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.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -4352,7 +4458,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -4367,7 +4473,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "0.4.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
@@ -4647,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",
@@ -5401,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"
@@ -5489,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"
@@ -5644,6 +5780,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"libc",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@@ -6539,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"
@@ -6552,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"
@@ -6580,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"
@@ -6715,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"
@@ -6911,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.4.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.
@@ -1624,6 +1624,7 @@ private fun FlavorChoice.label() = when (this) {
FlavorChoice.FRAPPE -> "Catppuccin Frappé" FlavorChoice.FRAPPE -> "Catppuccin Frappé"
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato" FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
FlavorChoice.MOCHA -> "Catppuccin Mocha" FlavorChoice.MOCHA -> "Catppuccin Mocha"
FlavorChoice.PERSONA5 -> "Persona 5"
} }
/** /**
@@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes(
/** The radius `pstr-app` draws everything at. */ /** The radius `pstr-app` draws everything at. */
internal const val CORNER = 8f internal const val CORNER = 8f
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */ /** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
internal const val CORNER_TIGHT = 4f internal const val CORNER_TIGHT = 4f
/** A bottom sheet, which has no desktop counterpart. */ /** A bottom sheet, which has no desktop counterpart. */
+24 -4
View File
@@ -152,6 +152,7 @@ pub enum FlavorChoice {
Frappe, Frappe,
Macchiato, Macchiato,
Mocha, Mocha,
Persona5,
} }
/// The one strong colour. The shared `pstr_core::appearance::Accent`. /// The one strong colour. The shared `pstr_core::appearance::Accent`.
@@ -165,6 +166,7 @@ pub enum AccentChoice {
Blue, Blue,
Teal, Teal,
Peach, Peach,
Red,
} }
/// What the viewer chose, stored where the desktop client reads it too. /// What the viewer chose, stored where the desktop client reads it too.
@@ -1125,13 +1127,27 @@ impl AndroidEngine {
title_key: String, title_key: String,
preferences: TrackPreferencesRecord, preferences: TrackPreferencesRecord,
) -> Result<(), BridgeError> { ) -> Result<(), BridgeError> {
let catalog = self.catalog.lock();
let existing = catalog
.title_track_prefs(&title_key)
.map_err(BridgeError::from_display)?
.unwrap_or_default();
let audio_language = normalized_language(preferences.audio_language);
let subtitle_language = normalized_language(preferences.subtitle_language);
// This bridge carries languages only. A track title stored beside one
// stays for as long as the language it was picked under does.
let preferences = TitleTrackPrefs { let preferences = TitleTrackPrefs {
audio_language: normalized_language(preferences.audio_language), audio_title: existing
subtitle_language: normalized_language(preferences.subtitle_language), .audio_title
.filter(|_| existing.audio_language == audio_language),
subtitle_title: existing
.subtitle_title
.filter(|_| existing.subtitle_language == subtitle_language),
audio_language,
subtitle_language,
subtitles: preferences.subtitles, subtitles: preferences.subtitles,
}; };
self.catalog catalog
.lock()
.set_title_track_prefs(&title_key, &preferences) .set_title_track_prefs(&title_key, &preferences)
.map_err(BridgeError::from_display) .map_err(BridgeError::from_display)
} }
@@ -2348,6 +2364,7 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
Flavor::Frappe => FlavorChoice::Frappe, Flavor::Frappe => FlavorChoice::Frappe,
Flavor::Macchiato => FlavorChoice::Macchiato, Flavor::Macchiato => FlavorChoice::Macchiato,
Flavor::Mocha => FlavorChoice::Mocha, Flavor::Mocha => FlavorChoice::Mocha,
Flavor::Persona5 => FlavorChoice::Persona5,
}, },
accent: match appearance.accent { accent: match appearance.accent {
Accent::Mauve => AccentChoice::Mauve, Accent::Mauve => AccentChoice::Mauve,
@@ -2358,6 +2375,7 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
Accent::Blue => AccentChoice::Blue, Accent::Blue => AccentChoice::Blue,
Accent::Teal => AccentChoice::Teal, Accent::Teal => AccentChoice::Teal,
Accent::Peach => AccentChoice::Peach, Accent::Peach => AccentChoice::Peach,
Accent::Red => AccentChoice::Red,
}, },
gradients: appearance.gradients, gradients: appearance.gradients,
} }
@@ -2371,6 +2389,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
FlavorChoice::Frappe => Flavor::Frappe, FlavorChoice::Frappe => Flavor::Frappe,
FlavorChoice::Macchiato => Flavor::Macchiato, FlavorChoice::Macchiato => Flavor::Macchiato,
FlavorChoice::Mocha => Flavor::Mocha, FlavorChoice::Mocha => Flavor::Mocha,
FlavorChoice::Persona5 => Flavor::Persona5,
}, },
accent: match record.accent { accent: match record.accent {
AccentChoice::Mauve => Accent::Mauve, AccentChoice::Mauve => Accent::Mauve,
@@ -2381,6 +2400,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
AccentChoice::Blue => Accent::Blue, AccentChoice::Blue => Accent::Blue,
AccentChoice::Teal => Accent::Teal, AccentChoice::Teal => Accent::Teal,
AccentChoice::Peach => Accent::Peach, AccentChoice::Peach => Accent::Peach,
AccentChoice::Red => Accent::Red,
}, },
gradients: record.gradients, gradients: record.gradients,
} }
+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,
+1178 -119
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 {
+199 -15
View File
@@ -84,38 +84,171 @@ impl Role {
} }
} }
/// 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 /// For the hand-painted parts, which lay text out through a `Painter` and
/// never see a `RichText`. /// never see a `RichText`.
pub fn font(self) -> egui::FontId { pub fn font(self) -> egui::FontId {
egui::FontId::proportional(self.size()) egui::FontId::new(self.size(), self.family())
} }
/// For the parts that go through egui's widgets. /// For the parts that go through egui's widgets.
pub fn rich(self, text: impl Into<String>) -> egui::RichText { pub fn rich(self, text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(self.size()) egui::RichText::new(text)
.size(self.size())
.family(self.family())
} }
} }
/// Install a real CJK fallback before the default egui fonts. egui ships a /// The spacing scale. Gaps between things come from here rather than being
/// compact Latin font, but it intentionally does not bundle the multi-megabyte /// picked per call site, so two neighbouring groups are 12 apart or 16 apart
/// CJK families. Native desktops already provide one, so use it when present. /// and never 14 in one place and 15 in the next.
pub fn install_font_fallbacks(ctx: &egui::Context) { 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")]
@@ -358,6 +491,36 @@ 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. /// 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 /// The other ramps here run between two opaque colours; this one runs down its
@@ -524,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);
@@ -686,6 +849,27 @@ const RAMP_STOPS: usize = 64;
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _;
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font
.charmap()
.mappings()
.map(|(codepoint, _)| codepoint)
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
.collect();
assert!(
shadowed.is_empty(),
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
shadowed.len(),
shadowed[0]
);
}
}
#[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());
+197 -135
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(theme::Role::Section.rich(title).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,85 +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("Delete")
.on_hover_text("Delete the offline copy; keep the online source")
.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 {
@@ -167,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");
}
}
+424 -35
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),
@@ -97,10 +349,10 @@ fn continue_row(
// to reach, so nothing to flex to. // to reach, so nothing to flex to.
width: theme::CARD_WIDTH, 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())));
} }
} }
@@ -111,7 +363,22 @@ 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 grid = ui::columns(ui.available_width()); let grid = ui::columns(ui.available_width());
let row_height = ui::card_height(grid.width);
for row in titles.chunks(grid.columns) { 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 // The gap the width was divided around, so what is drawn matches
// what was measured. egui's default item spacing is narrower, and // what was measured. egui's default item spacing is narrower, and
@@ -119,7 +386,7 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
// the left edge. // the left edge.
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; 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),
@@ -131,10 +398,10 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
badge: None, badge: None,
width: grid.width, 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())));
} }
} }
@@ -143,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.
/// ///
@@ -168,6 +509,37 @@ 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);
@@ -176,11 +548,11 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
.rich("Nothing in the library yet") .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));
} }
@@ -225,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");
+13 -14
View File
@@ -43,7 +43,7 @@ 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);
@@ -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(theme::Role::Caption.rich(error).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,12 +204,12 @@ 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(
theme::Role::Body theme::Role::Body
@@ -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(" · ")
} }
+310 -50
View File
@@ -4,13 +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 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;
@@ -48,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
@@ -62,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));
} }
} }
@@ -76,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))
} }
} }
@@ -95,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"];
@@ -113,14 +142,14 @@ 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(
theme::Role::Section theme::Role::Section
.rich(text) .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.
@@ -133,6 +162,184 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0)) filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
} }
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
///
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
/// 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. /// What [`filled`] paints behind its label.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum Fill { enum Fill {
@@ -176,15 +383,17 @@ pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<u
// the pill from overshooting when it moves between tabs of different // the pill from overshooting when it moves between tabs of different
// lengths — both edges arrive at the same time. // lengths — both edges arrive at the same time.
let ctx = ui.ctx(); let ctx = ui.ctx();
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16); let left =
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16); 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( let rect = Rect::from_min_max(
egui::pos2(left, target.top()), egui::pos2(left, target.top()),
egui::pos2(right, target.bottom()), egui::pos2(right, target.bottom()),
); );
ui.painter().set( ui.painter().set(
pill, pill,
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)), theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
); );
} }
@@ -225,17 +434,21 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
return response; return response;
} }
let radius = CornerRadius::same(8); let radius = CornerRadius::same(theme::radius::MD);
// Eased rather than switched, so the veil arrives over a few frames instead // Eased rather than switched, so the veil arrives over a few frames instead
// of appearing whole the moment the pointer crosses the edge. The press is // of appearing whole the moment the pointer crosses the edge. The press is
// quicker than the hover: a click should feel like it registered, not like // quicker than the hover: a click should feel like it registered, not like
// it was considered. // it was considered.
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10); let hover = ctx.animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let press = ctx.animate_bool_with_time( let press = ctx.animate_bool_with_time(
response.id.with("press"), response.id.with("press"),
response.is_pointer_button_down_on(), response.is_pointer_button_down_on(),
0.05, theme::motion::PRESS,
); );
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press; let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
@@ -281,6 +494,17 @@ pub struct Card<'a> {
pub width: f32, 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.
/// ///
/// A landscape picture is cropped to fill: a grid of differently-shaped black /// A landscape picture is cropped to fill: a grid of differently-shaped black
@@ -292,14 +516,16 @@ pub struct Card<'a> {
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = 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;
} }
@@ -309,15 +535,21 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
// ride it, so they arrive together rather than each snapping on its own. // 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 // egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here. // the frame rate — and it needs no state kept here.
let hover = // Focus lights a tile the way the pointer does, so the keyboard can be
ui.ctx() // followed across the grid.
.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12); 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 picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of // the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle. // letters popping into stills into one settle.
let art_in = ui let art_in = ui.ctx().animate_bool_with_time(
.ctx() response.id.with("art"),
.animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22); card.art.is_some(),
theme::motion::ARRIVE,
);
let painter = ui.painter(); let painter = ui.painter();
if hover > 0.0 { if hover > 0.0 {
@@ -328,13 +560,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
// Under the picture, not instead of it: it is what shows through while the // 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. // art fades in, and it is the whole tile when there is no art to come.
if art_in < 1.0 { if art_in < 1.0 {
painter.text( placeholder(painter, image_rect, card.name, 1.0 - art_in);
image_rect.center(),
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::muted().gamma_multiply(1.0 - art_in),
);
} }
// White because a textured rect *multiplies* its fill by the texture; the // White because a textured rect *multiplies* its fill by the texture; the
@@ -374,7 +600,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
); );
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);
@@ -470,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));
} }
@@ -485,14 +711,55 @@ 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 a grid divides the width it was given. /// How a grid divides the width it was given.
@@ -567,13 +834,6 @@ mod tests {
); );
} }
#[test]
fn initials_take_two_letters() {
assert_eq!(initials("Cowboy Bebop"), "CB");
assert_eq!(initials("Akira"), "A");
assert_eq!(initials(""), "");
}
#[test] #[test]
fn column_count_never_reaches_zero() { fn column_count_never_reaches_zero() {
assert_eq!(columns(0.0).columns, 1); assert_eq!(columns(0.0).columns, 1);
+506 -94
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.
@@ -303,6 +360,18 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
); );
} }
/// 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
@@ -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));
}, },
); );
@@ -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
theme::Role::Caption // coming and when, read in one glance.
.rich("Up next") 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(
theme::Role::Caption
.rich(format!("in {}s", card.seconds.ceil() as u32))
.color(theme::accent()),
);
});
});
ui.add(
egui::Label::new(
theme::Role::Body
.rich(&card.caption)
.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>,
@@ -535,7 +659,7 @@ fn chrome(
.add( .add(
egui::Button::new(theme::Role::Label.rich("Library")) 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,7 +667,7 @@ 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(
theme::Role::Heading theme::Role::Heading
@@ -559,18 +683,56 @@ fn chrome(
}); });
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(theme::Role::Subhead.rich("⛶")) 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
} }
+329 -151
View File
@@ -9,6 +9,7 @@ use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId}; use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::Action; use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
@@ -20,9 +21,17 @@ use crate::ui;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct Prefs { pub struct Prefs {
pub autoplay: bool, pub autoplay: bool,
pub auto_skip: bool,
pub appearance: Appearance, 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( pub fn show(
ui: &mut egui::Ui, ui: &mut egui::Ui,
settings: &mut MetadataConfig, settings: &mut MetadataConfig,
@@ -33,13 +42,22 @@ pub fn show(
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
appearance_form(ui, prefs.appearance, actions); // Centred, so a wide window frames the column rather than leaving it
// against one edge with the rest of the page empty.
ui.add_space(28.0); let width = ui.available_width().min(COLUMN);
playback_form(ui, prefs.autoplay, actions); let margin = ((ui.available_width() - width) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(28.0); ui.add_space(margin);
metadata_form(ui, settings, api_key, actions); 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);
});
});
}); });
} }
@@ -49,19 +67,14 @@ pub fn show(
/// there is no "apply" — because the preview *is* the app. A swatch row that /// 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. /// 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>) { fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
ui::section(ui, "Appearance"); ui::widgets::settings_group(ui, "Appearance", None, |ui| {
ui.add_space(theme::space::L);
egui::Frame::new() ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
.fill(theme::card()) ui.add_space(theme::space::M);
.corner_radius(egui::CornerRadius::same(8)) ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
.inner_margin(egui::Margin::same(14)) ui.horizontal_wrapped(|ui| {
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Palette"));
for flavor in Flavor::ALL { for flavor in Flavor::ALL {
if ui if palette_card(ui, appearance, flavor)
.radio(appearance.flavor == flavor, flavor.label())
.on_hover_text(flavor.description()) .on_hover_text(flavor.description())
.clicked() .clicked()
&& appearance.flavor != flavor && appearance.flavor != flavor
@@ -72,35 +85,123 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
})); }));
} }
} }
});
ui.add_space(theme::space::L);
ui.spacing_mut().item_spacing.y = 0.0;
ui.add_space(12.0); ui::widgets::settings_row(ui, "Accent", None, |ui| {
ui.label(ui::muted("Accent")); ui.spacing_mut().item_spacing.x = 8.0;
ui.horizontal_wrapped(|ui| { // Right to left, so reversed to read in the order they are listed.
for accent in Accent::ALL { for accent in Accent::ALL.into_iter().rev() {
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent { if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
actions.push(Action::SetAppearance(Appearance { actions.push(Action::SetAppearance(Appearance {
accent, accent,
..appearance ..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
}));
} }
}); });
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. /// One accent, drawn in the colours it would actually produce.
@@ -109,23 +210,22 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
/// the row previews what clicking it would give rather than what is on screen. /// 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 { fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
let selected = appearance.accent == accent; let selected = appearance.accent == accent;
let size = egui::Vec2::new(52.0, 26.0); let size = egui::Vec2::splat(26.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if ui.is_rect_visible(rect) { if ui.is_rect_visible(rect) {
let palette = theme::Palette::resolve(Appearance { let palette = theme::Palette::resolve(Appearance {
accent, accent,
..appearance ..appearance
}); });
let radius = egui::CornerRadius::same(6); let radius = egui::CornerRadius::same(13);
theme::swatch_fill(ui.painter(), rect, radius, &palette); theme::swatch_fill(ui.painter(), rect, radius, &palette);
if selected { if selected {
// A ring rather than a tick: the tick would have to be drawn in an // A ring rather than a tick: the tick would have to be drawn in an
// ink that reads on eight different fills. // ink that reads on eight different fills.
ui.painter().rect_stroke( ui.painter().circle_stroke(
rect.expand(2.0), rect.center(),
egui::CornerRadius::same(8), rect.width() / 2.0 + 3.0,
egui::Stroke::new(2.0, theme::text()), egui::Stroke::new(2.0, theme::text()),
egui::StrokeKind::Outside,
); );
} }
} }
@@ -134,34 +234,120 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
.on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_cursor(egui::CursorIcon::PointingHand)
} }
/// The handful of playback settings that are not per-file. /// The playback settings that are not per-file.
/// ///
/// Volume, audio language and subtitle language are all set from the player /// Volume, audio language, subtitle language and speed are all set from the
/// itself, where a viewer can hear the result — only autoplay has no natural /// player itself, where a viewer can hear the result.
/// home there, because by the time it matters the episode is over. fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) { ui::widgets::settings_group(
ui::section(ui, "Playback"); 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));
}
},
);
},
);
}
egui::Frame::new() /// The streaming cache: how big it may get, and a way to empty it.
.fill(theme::card()) fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
.corner_radius(egui::CornerRadius::same(8)) ui::widgets::settings_group(
.inner_margin(egui::Margin::same(14)) ui,
.show(ui, |ui| { "Storage",
ui.set_width(ui.available_width().min(640.0)); 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);
}
},
);
},
);
}
let mut enabled = autoplay; /// What this is, and which version.
if ui fn about(ui: &mut egui::Ui) {
.checkbox(&mut enabled, "Play the next episode automatically") ui::widgets::settings_group(ui, "About", None, |ui| {
.on_hover_text("Only when an episode reaches its end, never after a failure") ui::widgets::settings_row(
.changed() ui,
{ "proton-stream",
actions.push(Action::SetAutoplay(enabled)); Some("Streams Proton Drive share links without an account or a download step."),
} |ui| {
ui.label(ui::muted( ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
"Volume, audio language and subtitles are set from the player, and remembered \ },
from there.", );
)); 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. /// Enrichment: the switch, the provider, and the cost of turning it on.
@@ -176,94 +362,86 @@ fn metadata_form(
api_key: &mut String, api_key: &mut String,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
ui::section(ui, "Posters and descriptions"); ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
ui.add_space(theme::space::L);
egui::Frame::new() ui.spacing_mut().item_spacing.y = 6.0;
.fill(theme::card()) ui.label(ui::muted(
.corner_radius(egui::CornerRadius::same(8)) "A share filled by the Proton Drive desktop client carries no thumbnails, so \
.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 \ 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 \ 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.", the provider you choose, over HTTPS, each time a new one appears.",
)); ));
ui.add_space(10.0); ui.add_space(theme::space::L);
let mut changed = false; let mut changed = false;
let mut enabled = settings.enabled; let mut enabled = settings.enabled;
if ui if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
.checkbox(&mut enabled, "Look up posters and descriptions") settings.enabled = enabled;
.changed() changed = true;
{ }
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.enabled { if settings.provider.needs_api_key() {
ui.add_space(10.0); ui.add_space(theme::space::L);
ui.label(ui::muted("Provider")); ui.label(ui::muted(format!("{} API key", settings.provider.label())));
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| { ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() { ui.add(
actions.push(Action::MatchTitles { force: false }); egui::TextEdit::singleline(api_key)
} .password(true)
if ui .hint_text("v3 API key")
.button("Match everything again") .desired_width(300.0),
.on_hover_text("Ask about every title, including ones already matched") );
.clicked() if ui.button("Save key").clicked() {
{ actions.push(Action::SetApiKey {
actions.push(Action::MatchTitles { force: true }); provider: settings.provider,
key: std::mem::take(api_key),
});
} }
}); });
} 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 \ "Stored in your system keyring, never in a config file. Leave the box \
already stored.", empty and save to forget it.",
)); ));
} }
if changed { ui.add_space(theme::space::L);
actions.push(Action::SetMetadataConfig(settings.clone())); 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);
});
} }
+137 -30
View File
@@ -7,44 +7,59 @@
//! typed. //! typed.
use pstr_core::Share; use pstr_core::Share;
use pstr_core::library::Library;
use crate::app::{Action, ShareForm}; use crate::app::{Action, ShareForm};
use crate::theme; use crate::theme;
use crate::ui; use crate::ui;
pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) { pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
library: &Library,
form: &mut ShareForm,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.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.")); ui.label(ui::muted(
"No shares yet. Add one below and its contents become your library.",
));
} }
for share in shares { for share in shares {
share_row(ui, share, actions); let titles = library
.titles
.iter()
.filter(|title| title.share_ids.contains(&share.id))
.count();
share_row(ui, share, titles, actions);
} }
ui.add_space(22.0); ui.add_space(theme::space::XXL);
add_form(ui, form, actions); 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| {
@@ -61,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));
@@ -85,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)
@@ -107,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"))
@@ -127,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()
}
+529 -223
View File
@@ -30,65 +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
theme::Role::Subhead // watched. The one opened first is the one with the next episode.
.rich(format!( let picked_id = ui.id().with(("season", &title.key));
"{} · {}", let default = title
season.label(), .next_up()
ui::library::plural(season.episodes.len(), "episode") .and_then(|next| {
)) 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 .data(|data| data.get_temp::<usize>(picked_id))
.button("Delete season") .unwrap_or(default)
.on_hover_text("Delete the offline copies; keep the online source") .min(title.seasons.len().saturating_sub(1));
.clicked()
{ let Some(season) = title.seasons.get(picked) else {
for key in season_keys { return;
actions.push(Action::RemoveDownload(key, false)); };
} ui.horizontal(|ui| {
} if title.seasons.len() > 1 {
} else if ui let labels: Vec<String> =
.button("Download season") title.seasons.iter().map(|season| season.label()).collect();
.on_hover_text("Download every episode in this season") let choices: Vec<(usize, &str)> = labels
.clicked() .iter()
{ .enumerate()
actions.push(Action::MakeOffline( .map(|(index, label)| (index, label.as_str()))
season .collect();
.episodes if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
.iter() picked = index;
.map(|episode| PlaybackTarget::new(title, episode)) ui.data_mut(|data| data.insert_temp(picked_id, index));
.collect(),
));
}
for episode in &season.episodes {
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,
@@ -113,22 +201,34 @@ fn header(
.as_ref() .as_ref()
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop))); .map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
ui.add_space(6.0); 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: picture, 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(
width: theme::CARD_WIDTH, 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| {
// 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()); 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
@@ -142,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}")
} }
@@ -160,6 +268,19 @@ 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 {
@@ -202,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
@@ -235,14 +342,14 @@ 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 { if let Some((texture, index)) = backdrop {
ui.add_space(10.0); ui.add_space(theme::space::L);
band(ui, &texture, index); band(ui, &texture, index);
} }
} }
@@ -299,7 +406,10 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
egui::epaint::RectShape::filled( egui::epaint::RectShape::filled(
rect, rect,
egui::CornerRadius::ZERO, egui::CornerRadius::ZERO,
theme::background().gamma_multiply(if ramps { 0.90 } else { 0.94 }), // 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(), .into(),
); );
@@ -340,14 +450,53 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
/// 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(egui::Label::new(theme::Role::Label.rich(overview).color(theme::text())).wrap()); // Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes —
// what the page is for — below the fold.
const LINES: usize = 4;
let id = ui.id().with(("overview", &found.remote_id));
let open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
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,
@@ -383,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"
@@ -398,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<'_>,
@@ -417,158 +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(theme::Role::Label.rich("▶")).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("Delete")
.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(theme::Role::Caption.rich("empty").color(theme::danger())) let mut job = egui::text::LayoutJob::simple(
.on_hover_text( overview.clone(),
"The share reports no content for this file; it cannot be played.", theme::Role::Caption.font(),
); theme::muted(),
} text_width,
Some(size) => { );
ui.label(ui::muted(ui::format_size(size))); job.wrap.max_rows = 2;
} job.wrap.overflow_character = Some('…');
None => {} ui.add(egui::Label::new(job)).on_hover_text(overview);
} }
if let Some(at) = episode.resume_at() { });
ui.label(
theme::Role::Caption // A fixed height, the still's. `with_layout` would hand the
.rich(format!("resume {}", ui::format_time(at))) // buttons all the height left in the scroll area's viewport and
.color(theme::accent()), // centre them in it, so whichever row sat highest on screen
); // stretched down to the bottom of the window.
} 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| {
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");
}
}
+255 -100
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,91 +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(theme::Role::Label.rich("Back to video")) 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)); ),
);
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
if hover > 0.0 {
painter.rect_filled(
rect,
radius,
Color32::from_black_alpha((110.0 * hover) as u8),
);
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)),
)
.on_hover_text("Show the picture again")
.clicked()
{
actions.push(Action::Goto(Page::Player));
}
}
} }
ui.add_space(10.0); ui.add_space(theme::space::L);
ui.vertical(|ui| { ui.vertical(|ui| {
// Back to what is playing: after ten minutes of browsing, the bar ui.horizontal(|ui| {
// is the only thing that still knows which title this came from. ui.set_min_height(ROW_HEIGHT_MINI);
if ui
.add(
egui::Label::new(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| { 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.
@@ -359,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()
{ {
@@ -429,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>) {
@@ -470,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.
@@ -541,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);
}
}
+78 -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",
} }
} }
} }
@@ -423,6 +436,36 @@ const MOCHA: Ramp = Ramp {
light: false, 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 { impl Ramp {
const fn of(flavor: Flavor) -> Self { const fn of(flavor: Flavor) -> Self {
match flavor { match flavor {
@@ -431,6 +474,7 @@ impl Ramp {
Flavor::Frappe => FRAPPE, Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO, Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA, Flavor::Mocha => MOCHA,
Flavor::Persona5 => PERSONA5,
} }
} }
@@ -450,6 +494,7 @@ impl Ramp {
Accent::Blue => (self.blue, self.sapphire), Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green), Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow), Accent::Peach => (self.peach, self.yellow),
Accent::Red => (self.red, self.pink),
} }
} }
} }
@@ -630,6 +675,36 @@ mod tests {
} }
} }
#[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] #[test]
fn turning_gradients_off_leaves_one_colour_to_draw() { fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance { let flat = Palette::resolve(Appearance {
+109 -23
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;
@@ -297,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(())
} }
@@ -510,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<_, _>, _>>()?)
} }
@@ -559,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
@@ -567,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(())
} }
@@ -1391,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]
@@ -1539,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,
} }
} }
+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")
+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![
+75 -18
View File
@@ -337,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
@@ -394,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/")
+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));
}
}
+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?)
} }
} }
+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));
+57 -9
View File
@@ -70,7 +70,8 @@ pub struct DiskStats {
pub(crate) struct DiskCache { pub(crate) struct DiskCache {
root: PathBuf, root: PathBuf,
budget: u64, /// Atomic so it can be changed under a running cache, from settings.
budget: std::sync::atomic::AtomicU64,
index: Mutex<Index>, index: Mutex<Index>,
} }
@@ -115,7 +116,7 @@ impl DiskCache {
let cache = Self { let cache = Self {
root: config.root, root: config.root,
budget: config.budget_bytes, budget: std::sync::atomic::AtomicU64::new(config.budget_bytes),
index: Mutex::new(Index { index: Mutex::new(Index {
lru, lru,
bytes, bytes,
@@ -169,7 +170,7 @@ impl DiskCache {
/// written is a slower app, not a broken one. /// written is a slower app, not a broken one.
pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) { pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) {
let len = block.len() as u64; let len = block.len() as u64;
if len > self.budget { if len > self.budget() {
return; return;
} }
@@ -190,6 +191,30 @@ impl DiskCache {
self.evict_to_budget().await; self.evict_to_budget().await;
} }
fn budget(&self) -> u64 {
self.budget.load(std::sync::atomic::Ordering::Relaxed)
}
/// Change the budget, shrinking the cache to it at once if it is now over.
pub(crate) async fn set_budget(&self, bytes: u64) {
self.budget
.store(bytes, std::sync::atomic::Ordering::Relaxed);
self.evict_to_budget().await;
}
/// Delete every entry. The cache carries on, empty, under the same budget.
pub(crate) async fn clear(&self) {
let doomed: Vec<PathBuf> = {
let mut index = self.lock();
let doomed = std::iter::from_fn(|| index.lru.pop_lru().map(|(path, _)| path)).collect();
index.bytes = 0;
doomed
};
for path in doomed {
self.discard(&path).await;
}
}
pub(crate) fn stats(&self) -> DiskStats { pub(crate) fn stats(&self) -> DiskStats {
let index = self.lock(); let index = self.lock();
DiskStats { DiskStats {
@@ -204,7 +229,7 @@ impl DiskCache {
loop { loop {
let doomed = { let doomed = {
let mut index = self.lock(); let mut index = self.lock();
if index.bytes <= self.budget { if index.bytes <= self.budget() {
return; return;
} }
match index.lru.pop_lru() { match index.lru.pop_lru() {
@@ -321,11 +346,9 @@ async fn write_entry(path: &Path, block: &[u8]) -> Result<()> {
} }
tokio::fs::rename(&temp, path).await?; tokio::fs::rename(&temp, path).await?;
let mut sidecar = tokio::fs::File::create(meta_path(path)).await?; // Not synced. The block's sync is the one the ordering depends on; a
sidecar // sidecar lost to a crash only turns a valid entry into a miss.
.write_all(block.len().to_string().as_bytes()) tokio::fs::write(meta_path(path), block.len().to_string()).await?;
.await?;
sidecar.sync_all().await?;
Ok(()) Ok(())
} }
@@ -513,6 +536,31 @@ mod tests {
std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&root).ok();
} }
#[tokio::test]
async fn a_cleared_cache_is_empty_on_disk_and_in_its_index() {
let root = temp_root("clear");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
for index in 0..3 {
cache.put(&key("a", "rev1", index), &[7u8; 100]).await;
}
cache.clear().await;
assert_eq!(cache.stats().stored_bytes, 0);
assert!(cache.get(&key("a", "rev1", 0)).await.is_none());
let reopened = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
assert_eq!(reopened.stats().entries, 0);
}
#[tokio::test]
async fn lowering_the_budget_shrinks_a_running_cache() {
let root = temp_root("lower");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
for index in 0..5 {
cache.put(&key("a", "rev1", index), &[7u8; 100]).await;
}
cache.set_budget(250).await;
assert!(cache.stats().stored_bytes <= 250);
}
/// Reopening with a smaller budget must shrink the cache, not sit over it /// Reopening with a smaller budget must shrink the cache, not sit over it
/// until enough new blocks arrive to trigger eviction. /// until enough new blocks arrive to trigger eviction.
#[tokio::test] #[tokio::test]
+93 -1
View File
@@ -10,15 +10,23 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient; use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
use crate::block::{BlockMap, BlockSource, SharedBlocks}; use crate::block::{BlockMap, BlockSource, SharedBlocks};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::source::RevisionOpener; use crate::source::RevisionOpener;
/// How many times one block is fetched before its failure reaches the reader.
const BLOCK_ATTEMPTS: u32 = 3;
/// The pause before a block's second fetch, doubled before each one after.
const BLOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
/// One open revision on a public link. /// One open revision on a public link.
pub struct RevisionBlocks { pub struct RevisionBlocks {
reader: proton_drive_rs::RevisionReader, reader: proton_drive_rs::RevisionReader,
@@ -50,7 +58,7 @@ impl BlockSource for RevisionBlocks {
))); )));
}; };
let block = self.reader.read_at(start, size).await?; let block = fetch_retrying(BLOCK_RETRY_DELAY, || self.reader.read_at(start, size)).await?;
// A short block here is not a tail — the map says how long it is. It // A short block here is not a tail — the map says how long it is. It
// means the revision changed underneath us or the block table lied, and // means the revision changed underneath us or the block table lied, and
// serving it would silently shift every later byte. // serving it would silently shift every later byte.
@@ -64,6 +72,38 @@ impl BlockSource for RevisionBlocks {
} }
} }
/// Fetch a block, again if the failure is one a replay could get past.
///
/// The SDK retries a request that fails before its response starts, but not a
/// block body cut off part way through: that error reaches us as it happened,
/// and without this it stops playback or fails a download. Its keep-alive pings
/// turn a silently dead connection into exactly that error, seconds in rather
/// than at the storage timeout, and the next attempt goes out on a new
/// connection. [`ProtonError::is_retriable`] decides what counts, so a
/// permanent refusal still fails on the first attempt.
async fn fetch_retrying<T, F, Fut>(
first_delay: Duration,
mut fetch: F,
) -> std::result::Result<T, ProtonError>
where
F: FnMut() -> Fut,
Fut: Future<Output = std::result::Result<T, ProtonError>>,
{
let mut delay = first_delay;
let mut attempt = 1;
loop {
match fetch().await {
Err(error) if attempt < BLOCK_ATTEMPTS && error.is_retriable() => {
tracing::debug!(attempt, %error, "block fetch failed; fetching again");
tokio::time::sleep(delay).await;
delay *= 2;
attempt += 1;
}
result => return result,
}
}
}
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv /// A fully-downloaded revision. It uses the same block seam as Proton so mpv
/// can seek normally without a second playback path. /// can seek normally without a second playback path.
pub struct FileBlocks { pub struct FileBlocks {
@@ -136,8 +176,60 @@ impl RevisionOpener for LibraryOpener {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use proton_sdk::api::ResponseCode;
use proton_sdk::error::ProtonApiError;
use super::*; use super::*;
fn api_error(http_status: u16) -> ProtonError {
ProtonError::Api(ProtonApiError {
code: ResponseCode::Unknown,
http_status,
message: String::new(),
details: None,
})
}
/// Run [`fetch_retrying`] over a fetch that fails with `failures` in turn and
/// then succeeds, returning the result and how many fetches it took.
async fn fetch_through(
failures: Vec<ProtonError>,
) -> (std::result::Result<u8, ProtonError>, u32) {
let calls = AtomicU32::new(0);
let failures = std::sync::Mutex::new(failures.into_iter());
let result = fetch_retrying(Duration::ZERO, || {
calls.fetch_add(1, Ordering::Relaxed);
let next = failures.lock().unwrap().next();
async move { next.map_or(Ok(7), Err) }
})
.await;
(result, calls.into_inner())
}
#[tokio::test]
async fn a_block_that_fails_retriably_is_fetched_again() {
let (result, calls) = fetch_through(vec![api_error(503)]).await;
assert_eq!(result.expect("second fetch succeeds"), 7);
assert_eq!(calls, 2);
}
#[tokio::test]
async fn a_permanent_refusal_is_not_fetched_again() {
let (result, calls) = fetch_through(vec![api_error(403)]).await;
assert!(result.is_err());
assert_eq!(calls, 1);
}
#[tokio::test]
async fn a_block_that_keeps_failing_returns_its_last_error() {
let (result, calls) =
fetch_through((0..BLOCK_ATTEMPTS + 1).map(|_| api_error(503)).collect()).await;
assert!(result.is_err());
assert_eq!(calls, BLOCK_ATTEMPTS);
}
/// The player hands its stream to mpv's demuxer thread, so everything from /// The player hands its stream to mpv's demuxer thread, so everything from
/// the opener down has to cross threads. A compile-time guard, because the /// the opener down has to cross threads. A compile-time guard, because the
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in /// failure mode is an unhelpful `FnOnce is not general enough` error deep in
+33
View File
@@ -155,6 +155,24 @@ impl StreamSource {
.await .await
} }
/// Open a file to copy it out whole, rather than to play it.
///
/// Not the playback stream, and not shared with it: its blocks are read
/// once and written to a file, so caching them in memory only evicts the
/// blocks a viewer is about to need, and caching them on disk stores every
/// byte twice while pushing out what was just watched. It does still read
/// the disk cache — an episode watched and then downloaded is not fetched
/// again.
pub async fn open_for_copy(&self, share_id: &str, uid: &NodeUid) -> Result<VideoStream> {
let blocks = self.inner.opener.open(share_id, uid).await?;
Ok(VideoStream::copying(
uid.clone(),
blocks,
Arc::new(BlockRing::new(0)),
self.inner.disk.clone(),
))
}
/// Drop a stream and everything cached for it in memory. /// Drop a stream and everything cached for it in memory.
/// ///
/// The disk cache is deliberately untouched: it exists precisely so the next /// The disk cache is deliberately untouched: it exists precisely so the next
@@ -182,6 +200,21 @@ impl StreamSource {
pub fn disk_stats(&self) -> Option<crate::disk::DiskStats> { pub fn disk_stats(&self) -> Option<crate::disk::DiskStats> {
self.inner.disk.as_ref().map(|disk| disk.stats()) self.inner.disk.as_ref().map(|disk| disk.stats())
} }
/// Change the disk cache's budget, evicting at once if it is now over.
pub async fn set_disk_budget(&self, bytes: u64) {
if let Some(disk) = &self.inner.disk {
disk.set_budget(bytes).await;
}
}
/// Empty the disk cache. Streams already open carry on; what they had
/// cached is fetched again if it is wanted.
pub async fn clear_disk_cache(&self) {
if let Some(disk) = &self.inner.disk {
disk.clear().await;
}
}
} }
impl Inner { impl Inner {
+151 -3
View File
@@ -103,6 +103,11 @@ struct Inner {
map: BlockMap, map: BlockMap,
ring: Arc<BlockRing>, ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>, disk: Option<Arc<DiskCache>>,
/// Whether blocks fetched from the network are written to `disk`. Off for
/// a stream being copied out to a file, which reads what the cache already
/// holds but should not push what a viewer just watched out of it to make
/// room for bytes that are about to exist on disk anyway.
fill_disk: bool,
/// One fetch per block, however many readers want it. /// One fetch per block, however many readers want it.
flight: Arc<SingleFlight<BlockKey, CachedBlock>>, flight: Arc<SingleFlight<BlockKey, CachedBlock>>,
readahead: usize, readahead: usize,
@@ -131,6 +136,28 @@ impl VideoStream {
ring: Arc<BlockRing>, ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>, disk: Option<Arc<DiskCache>>,
readahead: usize, readahead: usize,
) -> Self {
Self::build(uid, blocks, ring, disk, readahead, true)
}
/// A stream that reads through the disk cache but never adds to it. See
/// [`crate::StreamSource::open_for_copy`].
pub(crate) fn copying(
uid: NodeUid,
blocks: SharedBlocks,
ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>,
) -> Self {
Self::build(uid, blocks, ring, disk, 0, false)
}
fn build(
uid: NodeUid,
blocks: SharedBlocks,
ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>,
readahead: usize,
fill_disk: bool,
) -> Self { ) -> Self {
let map = BlockMap::new(blocks.block_sizes()); let map = BlockMap::new(blocks.block_sizes());
let readahead = clamp_readahead(readahead, ring.budget(), blocks.block_sizes()); let readahead = clamp_readahead(readahead, ring.budget(), blocks.block_sizes());
@@ -141,6 +168,7 @@ impl VideoStream {
map, map,
ring, ring,
disk, disk,
fill_disk,
flight: Arc::new(SingleFlight::new()), flight: Arc::new(SingleFlight::new()),
readahead, readahead,
readahead_slots: Arc::new(Semaphore::new(readahead.max(1))), readahead_slots: Arc::new(Semaphore::new(readahead.max(1))),
@@ -176,6 +204,38 @@ impl VideoStream {
self.inner.blocks.block_sizes() self.inner.blocks.block_sizes()
} }
/// Start fetching the first and last blocks, without waiting for them.
///
/// Every demuxer reads the head of a file first, and the containers this
/// app plays keep their index at the tail — Matroska's cues, an MP4's
/// `moov` when it was not written for streaming. Opening a file is
/// therefore two round trips, one after the other. Asked for together,
/// they are one. The player's own reads join these through the
/// single-flight, so nothing is fetched twice.
///
/// Detached rather than read-ahead: the jump to the tail looks like a
/// seek, and a seek cancels read-ahead — which here would cancel the very
/// fetch it was meant to have started.
pub fn warm_edges(&self) {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let count = self.inner.map.len();
let edges: &[usize] = match count {
0 => &[],
1 => &[0],
_ => &[0, count - 1],
};
for &index in edges {
let inner = Arc::clone(&self.inner);
runtime.spawn(async move {
if let Err(error) = block_at(inner, index).await {
tracing::debug!(index, %error, "warming an edge block failed");
}
});
}
}
/// Fill `buf` from `offset`, returning how many bytes were written. /// Fill `buf` from `offset`, returning how many bytes were written.
/// ///
/// A read at or past EOF returns 0 rather than failing — that is how a /// A read at or past EOF returns 0 rather than failing — that is how a
@@ -413,10 +473,17 @@ async fn block_at(inner: Arc<Inner>, index: usize) -> Result<CachedBlock> {
.fetched_bytes .fetched_bytes
.fetch_add(bytes.len() as u64, Ordering::Relaxed); .fetch_add(bytes.len() as u64, Ordering::Relaxed);
if let Some(disk) = inner.disk.as_ref() {
disk.put(&fetch_key, &bytes).await;
}
let block: CachedBlock = bytes.into(); let block: CachedBlock = bytes.into();
// Written behind the read, not before it: the cache is disposable,
// and a block the player is waiting for should not also wait for a
// 4 MiB write and its fsync. That wait was on every cold block,
// which is to say on the first frame and on every seek.
if let Some(disk) = inner.disk.as_ref().filter(|_| inner.fill_disk) {
let disk = Arc::clone(disk);
let key = fetch_key.clone();
let bytes = Arc::clone(&block);
tokio::spawn(async move { disk.put(&key, &bytes).await });
}
inner.ring.insert(fetch_key, Arc::clone(&block)); inner.ring.insert(fetch_key, Arc::clone(&block));
Ok(block) Ok(block)
}) })
@@ -891,4 +958,85 @@ mod tests {
assert_eq!(stream.read_at(80, &mut buf).await.unwrap(), 20); assert_eq!(stream.read_at(80, &mut buf).await.unwrap(), 20);
assert_eq!(&buf[..20], &expected(80, 20)[..]); assert_eq!(&buf[..20], &expected(80, 20)[..]);
} }
async fn disk_in(tag: &str) -> Arc<DiskCache> {
let root = std::env::temp_dir().join(format!("pstr-stream-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
Arc::new(
DiskCache::open(crate::disk::DiskCacheConfig::new(root))
.await
.unwrap(),
)
}
/// Waits on a detached task without sleeping for a guessed duration.
async fn eventually(mut done: impl FnMut() -> bool) -> bool {
for _ in 0..200 {
if done() {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
done()
}
#[tokio::test]
async fn warming_fetches_the_first_and_last_blocks_and_nothing_between() {
let fake = Fake::new(&[1000; 10]);
let stream = stream_over(Arc::clone(&fake), 0);
stream.warm_edges();
assert!(eventually(|| fake.fetches_of(0) == 1 && fake.fetches_of(9) == 1).await);
assert_eq!(fake.fetches(), 2);
// And the player's own reads are then served without a second fetch.
stream.read_range(0, 10).await.unwrap();
stream.read_range(9_990, 10).await.unwrap();
assert_eq!(fake.fetches(), 2);
}
#[tokio::test]
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
let disk = disk_in("fill").await;
let fake = Fake::new(&[1000, 1000]);
let stream = VideoStream::new(
uid(),
fake,
Arc::new(BlockRing::new(DEFAULT_RING_BYTES)),
Some(Arc::clone(&disk)),
0,
);
assert_eq!(stream.read_range(0, 10).await.unwrap(), expected(0, 10));
assert!(eventually(|| disk.stats().entries == 1).await);
}
#[tokio::test]
async fn a_copying_stream_reads_the_disk_cache_but_never_adds_to_it() {
let disk = disk_in("copy").await;
let fake = Fake::new(&[1000, 1000]);
let key = BlockKey::new(&uid(), "rev1", 0);
disk.put(&key, &expected(0, 1000)).await;
let stream = VideoStream::copying(
uid(),
Arc::clone(&fake) as SharedBlocks,
Arc::new(BlockRing::new(0)),
Some(Arc::clone(&disk)),
);
assert_eq!(stream.read_range(0, 2000).await.unwrap(), expected(0, 2000));
assert_eq!(
fake.fetches_of(0),
0,
"block 0 should have come from the disk"
);
assert_eq!(fake.fetches_of(1), 1);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
disk.stats().entries,
1,
"the copy wrote into the playback cache"
);
}
} }
+6 -1
View File
@@ -120,4 +120,9 @@ an edge case.
| Catalog | data dir, `catalog.db` | Yes — recrawl | | Catalog | data dir, `catalog.db` | Yes — recrawl |
| Watch state | same DB, own table | **No** — deliberately survives a recrawl | | Watch state | same DB, own table | **No** — deliberately survives a recrawl |
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion | | Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each | | Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
| Desktop-only settings | config dir, `desktop.json` | Defaults if missing — hardware decoding, cache budget; never overwritten when unparseable |
| Window size | config dir, `window.json` | Defaults if missing or implausible |
| Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing |
| Notification app id (Windows) | `HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream`, icon in cache dir | Yes — written again the first time a session notifies |
+184
View File
@@ -146,6 +146,190 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B60 — a failed "Add and crawl" left the crawl spinner running forever
**Symptom.** Pasting a link without its `#` fragment, or one already added,
and pressing *Add and crawl* cleared the form and put "crawling" in the top
bar, where it stayed until the app was restarted. The Refresh button never
came back. The only sign of the real problem was a status line that had
already faded.
**Cause.** `Action::AddShare` set `crawling` and cleared the form before the
engine had answered. `Engine::add_share` reported a refused link through
`fail`, which sends a plain `Error`, so nothing ever sent `CrawlFinished` for
a crawl that never started.
**Fix.** The engine answers with `ShareAdded` or `ShareRejected`, and the
form keeps its contents until one of them arrives. A rejection clears
`crawling` and shows its reason inside the form. The link is also checked
while it is typed, using `pstr_core::shares::share_token`, the same check
`ShareStore::add` runs first, so most refusals never reach the engine.
**Verified.** `ui::shares::tests`: `a_link_without_its_key_is_refused_while_typing`
and `a_share_already_added_is_refused_while_typing`.
### B59 — every poster fell back to initials after each match run
**Symptom.** After *Match the library*, and after picking or forgetting a
match by hand, every tile in the grid dropped to its initials and faded the
same picture back in.
**Cause.** `Event::Metadata` cleared the whole poster cache, because any
title's record might have changed.
**Fix.** `app::changed_art` compares each title's tile-art URL before and
after, and only those titles are forgotten. A title whose URL did not change
keeps its texture.
**Verified.** `app::tests::a_match_run_that_changes_nothing_keeps_every_poster`
and `only_titles_whose_art_moved_lose_their_poster`.
### B58 — "Nothing in the library yet" flashed at every launch
**Symptom.** For the fraction of a second it took to read the catalog, the
library page showed the empty-library message and its *Add a share* button,
including for a library with hundreds of titles.
**Cause.** `App::library` starts as `Library::default()`, which is empty, and
the page could not tell "not read yet" from "nothing in it".
**Fix.** `App::loaded` is set by the first `LibraryLoaded`. Until then the
page draws two rows of blank tiles in the shape of the grid.
### B57 — subtitles drawn under the player's controls
**Symptom.** While the controls were up, the bottom subtitle line sat
behind the seek bar and the time labels and could not be read. The controls
are up whenever the mouse moves, which is when someone is watching.
**Cause.** mpv positions subtitles against the whole window. Nothing told it
that the bottom strip of the window was covered.
**Fix.** `Playback::lift_subtitles` sets mpv's `sub-pos` from the measured
height of the controls while they are visible, and back to 100 when they
hide. It sends a command only when the value changes. `sub-pos` rather than a
margin, because it also moves ASS subtitles.
### B56 — horizontal lines across the player's scrims
**Symptom.** Thin dark lines ran across the picture in the fade under the
title strip and the fade above the controls.
**Cause.** Each scrim was a stack of flat bands, each one pixel taller than
its step so no gap could open between them. Every overlap was a line drawn
at twice the alpha.
**Fix.** `ui::player::fade` draws one mesh with a colour per row of vertices,
and the GPU interpolates between them. There are no overlaps left to show.
### B55 — every newly fetched block waited for its own fsync before playback
**Symptom.** Not visible as a bug. Every block the player had to wait for
from the network, which means the first frame and the block after every
seek, also waited for a 4 MiB write to the block cache, an fsync of it, and an
fsync of its sidecar.
**Cause.** `block_at` awaited `DiskCache::put` before returning the block, and
`write_entry` synced both files.
**Fix.** The put runs in a spawned task behind the read. The sidecar is no
longer synced: the ordering the module note describes depends only on the
block's sync, and a sidecar lost in a crash only turns a valid entry into a
miss. Downloads now open their streams with `StreamSource::open_for_copy`,
which reads the disk cache but never writes to it. A download no longer
pushes the episode just watched out of the cache to store bytes that are about
to be on disk anyway.
**Verified.** `stream::tests::a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it`
and `a_copying_stream_reads_the_disk_cache_but_never_adds_to_it`.
### B54 — a window on another workspace was declared "not responding"
**Symptom.** On Hyprland, switching to another workspace while `proton-stream`
was playing brought up the compositor's "application is not responding" dialog
a few seconds later, offering to wait or to kill it. Choosing *wait* dismissed
it for good, and coming back to the workspace found the window working normally.
Wayland only; the app was never actually broken.
**Cause.** A Wayland compositor throttles a client by withholding the
`wl_surface` frame callback, and withholds it entirely while the surface is not
being shown. Mesa's EGL implements a swap interval of 1 by waiting on that
callback, so `eglSwapBuffers` on a hidden surface never returns:
`run_ui_and_paint` → `glutin swap_buffers` → `eglSwapBuffers` →
`wl_display_dispatch_queue` → `ppoll`, with no timeout. That is the UI thread,
and the UI thread is what answers `xdg_wm_base.ping`, so the compositor stops
hearing from the app and puts up the ANR dialog. It takes *sustained* repaints
to hit — the throttle waits on the previous frame's callback, so the first swap
after the window is hidden still returns — which is why it showed up during
playback, where mpv asks for a frame per picture, and not on an idle window.
eframe already skips painting a viewport it believes is hidden, but nothing
tells it: `WindowEvent::Occluded` is X11, macOS and web only — winit removed the
Wayland implementation in 0.29 — so `ViewportInfo::occluded` stays `None`.
**Fix.** `pstr-app::pacing`. On Wayland the swap interval is set to 0
(`NativeOptions::glow_options.vsync`), which makes `eglSwapBuffers` return
whether the surface is being shown or not, and `Pacer` caps the frame rate in
vsync's place — 60 fps by default, `PSTR_FRAME_CAP` to change it, `0` to remove
the ceiling. 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 are
untouched and keep vsync. The cap is a ceiling and not a rate, so an idle window
still draws nothing at all.
**Verified.** Reproduced on Hyprland 0.56.2 with a minimal eframe 0.35 client
that repaints continuously: moved to a hidden workspace with `hyprctl dispatch
movetoworkspacesilent`, its frame counter stopped dead and `gdb` put thread 1 in
`eglSwapBuffers` → `wl_display_dispatch_queue`. The same client with the swap
interval at 0 kept drawing while hidden and stayed in the event loop. The app
itself was then run hidden for 30 s: it stays in `calloop`'s poll, and steady
state costs 0% of a core, so the ceiling has not turned an idle window into a
busy one. `cargo test -p pstr-app pacing` covers the cap.
### B53 — scanning a new library outran AniList, and the crawl waited on one folder at a time
**Symptom.** Two slow starts, both on the first scan of a share. Matching a
library against AniList worked for the first couple of dozen titles and then
returned nothing but failures for the rest of the run; because a failure is
deliberately not cached, the next run started over and failed in the same place,
so a large library never finished matching. Separately, the crawl itself took
minutes on a share whose contents are a few hundred nodes.
**Cause.** Two independent ones.
- Nothing paced provider requests. A scan sends one search per title, a second
search for a name that needs its apostrophe repaired, one episode request per
match and up to three more per further season — several hundred requests as
fast as two workers could issue them, against an API that allows ninety a
minute and has been running in a degraded mode that allows thirty. Everything
past the window got a `429`, which `AniList::query` turned straight into
`Error::RateLimited` with no wait and no retry.
- `SharedLibrary::crawl` walked the tree one folder per round trip. A media
share is wide, not deep — a folder per series, a folder per season under it —
so a few hundred sibling folders were listed strictly in sequence. The SDK
already fans its detail batches out within one folder; nothing overlapped
across folders, and the crawl was almost entirely latency.
**Fix.** `pstr-meta::limiter` is a shared, self-tuning pacer: requests take a
slot before they are sent, `X-RateLimit-Limit` on any answer retunes the rate to
what the provider currently allows, and a `429` parks *every* lookup in flight
for as long as `Retry-After` asks (capped at 30s) and then retries, twice, before
giving up as `Error::RateLimited`. One limiter lives inside each provider, which
lives in an `Arc` inside `MetadataService`, so the per-title clones share it.
TMDB routes through the same code — its search request now goes through the same
paced `get` as everything else rather than building its own.
`SharedLibrary::crawl` is now breadth-first and lists `CRAWL_CONCURRENCY` (6)
folders of a level at once. `buffered`, not `buffer_unordered`: two identical
crawls should produce the same order. The client clones share one node-key cache
and one single-flight map, so siblings needing the same ancestor key still wait
on a single derivation.
**Verified.** `cargo fmt --all -- --check`, `cargo clippy --workspace
--all-targets -- -D warnings`, `cargo test --workspace --locked`. The limiter's
pacing, its `429` parking and its retuning from headers are covered by unit tests
on a paused clock (`crates/pstr-meta/src/limiter.rs`).
### B52 — "paint the accent as a gradient" did nothing on Android ### B52 — "paint the accent as a gradient" did nothing on Android
**Symptom.** The appearance page on Android offers a *Paint the accent as a **Symptom.** The appearance page on Android offers a *Paint the accent as a
+49 -10
View File
@@ -12,10 +12,11 @@ is the contract, and each side's copy is asserted against it by a test.
|---|---|---|---| |---|---|---|---|
| Colour roles | `pstr_core::appearance::Palette` | `ui/theme/Theme.kt` `schemeOf` | `SchemeRolesTest` | | Colour roles | `pstr_core::appearance::Palette` | `ui/theme/Theme.kt` `schemeOf` | `SchemeRolesTest` |
| Type ramp | `pstr_app::theme::Role` | `ui/theme/Type.kt` | `TypeRampTest` | | Type ramp | `pstr_app::theme::Role` | `ui/theme/Type.kt` | `TypeRampTest` |
| Corner radius | `pstr-app` `ui/mod.rs` | `ui/theme/Shape.kt` | `TypeRampTest` | | Corner radius | `pstr_app::theme::radius` | `ui/theme/Shape.kt` | `TypeRampTest` |
| Gradient rule | `Palette::resolve` | — (derived, see below) | `appearance.rs` tests | | Gradient rule | `Palette::resolve` | — (derived, see below) | `appearance.rs` tests |
| Depth | `pstr_app::theme::{tile_shadow, bar_shadow}` | Material elevation | — | | Depth | `pstr_app::theme::{tile_shadow, bar_shadow}` | Material elevation | — |
| Motion | `Context::animate_*` | Compose defaults | — | | Spacing | `pstr_app::theme::space` | Compose `dp` literals | — |
| Motion | `pstr_app::theme::motion` | Compose defaults | — |
## Type ## Type
@@ -35,8 +36,16 @@ screen and not for anything in a library grid).
| Caption | 12 | context rather than content: counts, sizes, errors under a field | | Caption | 12 | context rather than content: counts, sizes, errors under a field |
| Micro | 11 | the smallest thing that stays legible — a badge over artwork | | Micro | 11 | the smallest thing that stays legible — a badge over artwork |
Weight and leading are each side's own: egui has one weight, and Compose derives Weight and leading are each side's own. The desktop bundles Inter
line height as 1.35 × size rather than naming it nine more times. (`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
its semibold cut, which egui sees as a family of its own (`theme::semibold`);
everything below Section is the regular cut. Compose derives line height as
1.35 × size rather than naming it nine more times.
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
regular weight), installed right behind Inter so an icon can sit inside an
ordinary label. Do not use Unicode symbols such as ⏭ or 🔊 as icons: they come
out of whichever fallback font has them, at that font's size and baseline.
Both clients point their framework's *default* styles at the ramp — egui's five Both clients point their framework's *default* styles at the ramp — egui's five
`TextStyle`s, Compose's fifteen — so a stock widget nobody restyled still lands `TextStyle`s, Compose's fifteen — so a stock widget nobody restyled still lands
@@ -46,9 +55,19 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
## Shape ## Shape
**Radius 8** for everything either client paints: cards, buttons, the tab pill, **Radius 8** (`radius::MD`) for everything either client paints: cards,
progress bars, dialogs. **4** for a badge over artwork. **16** for an Android buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
bottom sheet, which has no desktop counterpart. also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
small control inside something else, and **12** (`LG`) for what floats — row
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has
no desktop counterpart.
## Spacing
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
24 (`XXS` to `XXL`). Use a step of the scale for a gap, not a literal. Sizes of
things — a still, a button, a column — are not spacing and stay where they are
defined.
Two traps, both Android's: Two traps, both Android's:
@@ -96,11 +115,31 @@ page is a smudge on a near-white one. Android gets Material's elevation, which
already adapts. Neither uses a tint to say "raised". already adapts. Neither uses a tint to say "raised".
Transitions on the desktop go through `Context::animate_*`: wall-clock driven, Transitions on the desktop go through `Context::animate_*`: wall-clock driven,
frame-rate independent, no state held by the caller. Durations in use are 0.05 s frame-rate independent, no state held by the caller. Durations come from
for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.22 s `theme::motion`: `PRESS` 0.05 s, `HOVER` 0.12 s, `STATE` 0.15 s (a switch, the
for artwork arriving. Compose's defaults are close enough that nothing here tab pill's travel, a page change), `FADE` 0.18 s (player chrome, toasts) and
`ARRIVE` 0.24 s (artwork over its placeholder). A hand-driven transition eases
with `motion::ease_out`. Compose's defaults are close enough that nothing here
overrides them. overrides them.
## Desktop widgets
`pstr-app/src/ui/widgets.rs` holds the hand-painted controls. Use them instead
of egui's own in new code:
- `toggle`: an on/off switch. It replaces `ui.checkbox`, whose unchecked box
is filled with the card colour and so disappears on a card.
- `settings_group` / `settings_row`: one card per subject, with the name and
description on the left and the control on the right (the libadwaita
preferences pattern `proton-drive-linux` uses).
- `segmented`: pills on a shared track, for picking one of a few values
(library filter, season).
- `chip`: a small rounded fact, such as a genre.
- `watched_mark`, `download_button`: the round icon buttons on an episode row.
Messages go through `ui::toast::Toasts` (via `App::note`), not a status line.
Destructive actions ask through `ui::confirm`.
## Adding a token ## Adding a token
Name it in the owning client first, restate it on the other side, and extend the Name it in the owning client first, restate it on the other side, and extend the
+11
View File
@@ -55,6 +55,17 @@ chosen by hand sets `alang`/`slang` for every file loaded afterwards — the poi
being that picking Japanese audio once picks it for the rest of the season. being that picking Japanese audio once picks it for the rest of the season.
A dragged volume slider writes to disk on release, not per frame. A dragged volume slider writes to disk on release, not per frame.
Done: **the track choice follows the title, by name as well as language.** A
track picked by hand is stored per title in the catalog (`title_track_prefs`,
schema v8 added `audio_title`/`subtitle_title`) as its language *and* the name
the muxer gave it. On `FileLoaded`, `playback::restore_tracks` re-selects the
track whose name matches (`pstr_player::pick_track`) — which is what tells
"Signs & Songs" from "Full Subtitles" when both are English, and finds an
untagged track at all; `alang`/`slang` could only ever say the language. A name
in the wrong language is not a match, and a title with only a language stored
keeps mpv's own pick. Android stores languages only and keeps a desktop-stored
name while its language is unchanged.
Done: the player page's overlay — a full-width seek bar with the file's chapters Done: the player page's overlay — a full-width seek bar with the file's chapters
marked on it and the current chapter named beside the clock, a transport cluster marked on it and the current chapter named beside the clock, a transport cluster
(previous / −10s / play / +30s / next), and volume plus the audio, subtitle and (previous / −10s / play / +30s / next), and volume plus the audio, subtitle and
+140
View File
@@ -0,0 +1,140 @@
import Foundation
import Observation
/// The app's one Rust engine and the screen state read from it.
///
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only
/// asks it questions off the main thread and keeps the answers for SwiftUI.
@MainActor
@Observable
final class AppModel {
let engine: AndroidEngine?
private(set) var startupError: String?
var titles: [TitleRecord] = []
var shares: [ShareRecord] = []
var search = ""
var crawling = false
var error: String?
init() {
do {
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
} catch {
engine = nil
startupError = String(describing: error)
}
}
/// Application Support survives updates and is backed up, which is what
/// the watch history wants. The block cache goes under Caches, which iOS
/// may purge and never backs up.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let config = support.appendingPathComponent("config", isDirectory: true)
let data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
struct NoEngine: Error, CustomStringConvertible {
var description: String { "the Rust engine did not start" }
}
/// Run a blocking bridge call off the main thread. The synchronous exports
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T {
guard let engine else { throw NoEngine() }
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
}
func report(_ error: Error) {
self.error = String(describing: error)
}
func reload() async {
let needle = search.trimmingCharacters(in: .whitespaces)
do {
let (titles, shares) = try await run { engine in
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
}
self.titles = titles
self.shares = shares
} catch {
report(error)
}
}
func crawl(shareId: String? = nil) async {
guard let engine, !crawling else { return }
crawling = true
defer { crawling = false }
do {
try await engine.crawl(shareId: shareId)
} catch {
report(error)
}
await reload()
}
func addShare(name: String, url: String, password: String?) async {
do {
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
await crawl(shareId: share.id)
} catch {
report(error)
}
}
func repairShare(id: String, url: String, password: String?) async {
do {
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
await crawl(shareId: id)
} catch {
report(error)
}
}
func removeShare(id: String) async {
do {
try await run { try $0.removeShare(shareId: id) }
} catch {
report(error)
}
await reload()
}
/// Marking seen puts the position at the duration where one is known, and
/// unwatching rewinds, as on desktop and Android.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
do {
try await run { engine in
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId, linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration, watched: watched
)
}
} catch {
report(error)
}
await reload()
}
/// Episodes started but not finished, most recent first.
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
titles
.flatMap { title in
title.seasons.flatMap(\.episodes)
.filter { $0.resumeAt != nil && !$0.watched }
.map { (title: title, episode: $0) }
}
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
}
}
@@ -0,0 +1,63 @@
import Foundation
import Security
/// The share URL fragment, custom passwords and metadata API keys, held in the
/// iOS Keychain. The counterpart of Android's `KeystoreSecretStore`.
///
/// Anything thrown from here reaches Rust as `UnexpectedUniFFICallbackError`,
/// which the bridge turns into an ordinary `BridgeError` rather than a panic on
/// whichever tokio worker asked (B18).
final class KeychainSecretStore: AndroidSecretStore, @unchecked Sendable {
private let service = "io.narl.protonstream.secrets"
struct KeychainError: Error, CustomStringConvertible {
let status: OSStatus
let action: String
var description: String {
let message = SecCopyErrorMessageString(status, nil) as String? ?? "status \(status)"
return "keychain \(action): \(message)"
}
}
private func query(_ key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
}
func set(key: String, value: String) throws {
let data = Data(value.utf8)
let update = SecItemUpdate(query(key) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if update == errSecSuccess { return }
guard update == errSecItemNotFound else { throw KeychainError(status: update, action: "update") }
var item = query(key)
item[kSecValueData as String] = data
// Readable by the refresh that runs while the phone is locked in a
// pocket, and never restored onto another device from a backup.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let add = SecItemAdd(item as CFDictionary, nil)
guard add == errSecSuccess else { throw KeychainError(status: add, action: "add") }
}
func get(key: String) throws -> String? {
var item = query(key)
item[kSecReturnData as String] = true
item[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(item as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError(status: status, action: "read")
}
return String(data: data, encoding: .utf8)
}
func delete(key: String) throws {
let status = SecItemDelete(query(key) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status, action: "delete")
}
}
}
@@ -0,0 +1,49 @@
import AVFoundation
import SwiftUI
@main
struct ProtonStreamApp: App {
@State private var model = AppModel()
init() {
// `.playback` keeps the audio going with the screen locked or the app
// in the background, which `UIBackgroundModes: audio` then permits.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
}
var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.preferredColorScheme(.dark)
}
}
}
struct RootView: View {
@Environment(AppModel.self) private var model
var body: some View {
if let failure = model.startupError {
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
} else {
TabView {
NavigationStack { LibraryView() }
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
NavigationStack { SharesView() }
.tabItem { Label("Shares", systemImage: "link") }
NavigationStack { SettingsView() }
.tabItem { Label("Settings", systemImage: "gearshape") }
}
.task { await model.reload() }
.alert("Something went wrong", isPresented: .init(
get: { model.error != nil },
set: { if !$0 { model.error = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(model.error ?? "")
}
}
}
}
@@ -0,0 +1,4 @@
// C declarations Swift sees without a module import: the UniFFI scaffolding
// generated beside PstrBridge.swift, and the stream ABI UniFFI does not cover.
#include "PstrBridgeFFI.h"
#include "pstr_stream.h"
+21
View File
@@ -0,0 +1,21 @@
// The blocking stream ABI exported by crates/pstr-android (`lib.rs`,
// `pstr_android_stream_*`). It is not part of the UniFFI interface on purpose:
// decrypted bytes go straight from Rust into libmpv's buffer and never pass
// through Swift values.
#pragma once
#include <stddef.h>
#include <stdint.h>
/// Read up to `length` bytes at `offset` into `buffer`. Returns the byte count,
/// 0 at end of file, or -1 on failure or cancellation.
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
/// The stream's size in bytes, or -1 for an unknown handle.
int64_t pstr_android_stream_size(uint64_t handle);
/// Interrupt the read in flight for this stream, if any.
void pstr_android_stream_cancel(uint64_t handle);
/// Drop the registry's reference. Call exactly once per published handle.
void pstr_android_stream_release(uint64_t handle);
+298
View File
@@ -0,0 +1,298 @@
import Foundation
import Libmpv
import Observation
import UIKit
/// MoltenVK can set the drawable to 1x1 to force a presentation through, which
/// flickers and can stick. MPVKit's demo carries the same guard
/// (mpv-player/mpv#13651).
final class MpvMetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { super.drawableSize }
set {
if Int(newValue.width) > 1, Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
}
struct MpvTrack: Identifiable, Hashable {
let id: Int64
let type: String
let language: String
let title: String
let selected: Bool
var label: String {
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
}
}
/// One libmpv core rendering into a Metal layer. Every public method runs on
/// the main thread; mpv's events are read on a thread of their own and applied
/// back on the main thread.
@MainActor
@Observable
final class MpvPlayer {
enum EndReason { case eof, stopped, failed }
private(set) var position: Double = 0
private(set) var duration: Double = 0
private(set) var paused = false
private(set) var buffering = false
private(set) var tracks: [MpvTrack] = []
private(set) var chapters: [ChapterRecord] = []
private(set) var failure: String?
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
/// that window still describes the *outgoing* file, including the
/// `END_FILE(stop)` stopping it emits.
private(set) var loading = false
/// Called once per file that ends, never for the one a load replaced.
@ObservationIgnored var onEnded: ((EndReason) -> Void)?
@ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer?
init() {
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
guard let mpv = mpv_create() else {
failure = "libmpv did not start"
return
}
var wid = layer
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
for (name, value) in [
("config", "no"),
("vo", "gpu-next"),
("gpu-api", "vulkan"),
("gpu-context", "moltenvk"),
("hwdec", "videotoolbox"),
("video-rotate", "no"),
("keep-open", "no"),
("cache", "yes"),
("cache-secs", "30"),
("demuxer-readahead-secs", "30"),
("demuxer-max-bytes", "50331648"),
("subs-fallback", "yes"),
] {
mpv_set_option_string(mpv, name, value)
}
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
mpv_terminate_destroy(mpv)
failure = "libmpv did not initialize"
return
}
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
mpv_request_log_messages(mpv, "error")
self.mpv = mpv
let thread = Thread { [weak self] in Self.eventLoop(mpv) { event in self?.apply(event) } }
thread.name = "mpv-events"
thread.start()
}
// MARK: Commands
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
/// still owns the handle's release.
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
guard mpv != nil else { return false }
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
setString("alang", audio ?? "")
setString("slang", subtitle ?? "")
setString("sid", subtitles ? "auto" : "no")
position = 0
duration = 0
tracks = []
chapters = []
failure = nil
loading = true
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
loading = false
return false
}
return true
}
func setPaused(_ value: Bool) { setFlag("pause", value) }
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
func setMuted(_ value: Bool) { setFlag("mute", value) }
func select(audio: Bool, track: MpvTrack?) {
let property = audio ? "aid" : "sid"
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
refreshTracks()
}
/// Showing no picture while backgrounded keeps the audio playing and avoids
/// the black frame MoltenVK otherwise leaves on return.
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
/// block fetch, and the event thread destroys the core once it is down.
func shutdown() {
guard let mpv else { return }
self.mpv = nil
mpv_command_string(mpv, "quit")
}
// MARK: Events
private enum Event {
case double(UInt64, Double)
case flag(UInt64, Bool)
case count(UInt64)
case loaded
case ended(Int32)
case log(String)
}
/// Runs until the core shuts down, then destroys it. Nothing else calls
/// `mpv_terminate_destroy`, so no main-thread call can race it.
private nonisolated static func eventLoop(_ mpv: OpaquePointer, _ deliver: @escaping @MainActor (Event) -> Void) {
while true {
guard let event = mpv_wait_event(mpv, -1)?.pointee else { continue }
var decoded: Event?
switch event.event_id {
case MPV_EVENT_SHUTDOWN:
mpv_terminate_destroy(mpv)
return
case MPV_EVENT_PROPERTY_CHANGE:
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
let data = property.data else { break }
switch property.format {
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata)
default: break
}
case MPV_EVENT_FILE_LOADED:
decoded = .loaded
case MPV_EVENT_END_FILE:
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
decoded = .ended(end.reason.rawValue)
}
case MPV_EVENT_LOG_MESSAGE:
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
let text = message.text {
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines))
}
default:
break
}
if let decoded {
DispatchQueue.main.async { deliver(decoded) }
}
}
}
private func apply(_ event: Event) {
switch event {
case let .double(id, value):
if loading { return }
if id == 1 { position = value } else if id == 2 { duration = value }
case let .flag(id, value):
if id == 3 { paused = value } else if id == 4 { buffering = value }
case let .count(id):
if loading { return }
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() }
case .loaded:
loading = false
refreshTracks()
refreshChapters()
case let .ended(reason):
// The outgoing file's END_FILE arrives after the next load began.
if loading { return }
switch reason {
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
case MPV_END_FILE_REASON_ERROR.rawValue:
failure = failure ?? "The file could not be played"
onEnded?(.failed)
default: onEnded?(.stopped)
}
case let .log(text):
if !text.isEmpty { failure = text }
}
}
private func refreshTracks() {
let count = getInt("track-list/count")
tracks = (0 ..< max(0, count)).map { index in
let base = "track-list/\(index)/"
return MpvTrack(
id: getInt(base + "id"),
type: getString(base + "type") ?? "",
language: getString(base + "lang") ?? "",
title: getString(base + "title") ?? "",
selected: getString(base + "selected") == "yes"
)
}
}
private func refreshChapters() {
let count = getInt("chapter-list/count")
chapters = (0 ..< max(0, count)).map { index in
let base = "chapter-list/\(index)/"
return ChapterRecord(index: index, title: getString(base + "title"), start: getDouble(base + "time"))
}
}
// MARK: Property plumbing
private func command(_ args: [String]) -> Int32 {
guard let mpv else { return -1 }
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
pointers.append(nil)
defer { pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) } }
return mpv_command(mpv, &pointers)
}
private func setString(_ name: String, _ value: String) {
guard let mpv else { return }
mpv_set_property_string(mpv, name, value)
}
private func setDouble(_ name: String, _ value: Double) {
guard let mpv else { return }
var value = value
mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
}
private func setFlag(_ name: String, _ value: Bool) {
guard let mpv else { return }
var flag: Int32 = value ? 1 : 0
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &flag)
}
private func getInt(_ name: String) -> Int64 {
guard let mpv else { return 0 }
var value: Int64 = 0
mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
return value
}
private func getDouble(_ name: String) -> Double {
guard let mpv else { return 0 }
var value = 0.0
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
return value
}
private func getString(_ name: String) -> String? {
guard let mpv, let raw = mpv_get_property_string(mpv, name) else { return nil }
defer { mpv_free(raw) }
return String(cString: raw)
}
}
@@ -0,0 +1,86 @@
import Foundation
import Libmpv
// libmpv's `pstr://<handle>` protocol, backed by the Rust stream registry. The
// Swift port of the stream half of android/app/src/main/cpp/pstr_mpv.cpp.
/// One open file on mpv's demuxer thread. `position` is only ever touched from
/// that thread; `cancelled` is also written from whichever thread cancels.
private final class StreamCookie {
let handle: UInt64
let size: Int64
var position: Int64 = 0
private let lock = NSLock()
private var _cancelled = false
init(handle: UInt64, size: Int64) {
self.handle = handle
self.size = size
}
var cancelled: Bool {
get { lock.withLock { _cancelled } }
set { lock.withLock { _cancelled = newValue } }
}
}
private func cookie(_ opaque: UnsafeMutableRawPointer?) -> StreamCookie? {
opaque.map { Unmanaged<StreamCookie>.fromOpaque($0).takeUnretainedValue() }
}
/// Register the protocol on a core that has not been initialized yet.
func registerStreamProtocol(_ mpv: OpaquePointer) -> Bool {
mpv_stream_cb_add_ro(mpv, "pstr", nil, streamOpen) >= 0
}
private func streamOpen(
_: UnsafeMutableRawPointer?,
_ uri: UnsafeMutablePointer<CChar>?,
_ info: UnsafeMutablePointer<mpv_stream_cb_info>?
) -> Int32 {
let failed = MPV_ERROR_LOADING_FAILED.rawValue
guard let uri, let info else { return failed }
let value = String(cString: uri)
guard value.hasPrefix("pstr://"), let handle = UInt64(value.dropFirst("pstr://".count)) else { return failed }
let size = pstr_android_stream_size(handle)
guard size >= 0 else { return failed }
info.pointee.cookie = Unmanaged.passRetained(StreamCookie(handle: handle, size: size)).toOpaque()
info.pointee.read_fn = { opaque, buffer, length in
guard let stream = cookie(opaque), let buffer else { return -1 }
if stream.cancelled { return -1 }
let remaining = stream.size - stream.position
if remaining <= 0 { return 0 }
let requested = Int(min(length, UInt64(remaining)))
let read = pstr_android_stream_read(stream.handle, UInt64(stream.position), buffer, requested)
if read > 0 { stream.position += read }
return read
}
info.pointee.seek_fn = { opaque, offset in
guard let stream = cookie(opaque), offset >= 0, offset <= stream.size else {
return Int64(MPV_ERROR_GENERIC.rawValue)
}
stream.position = offset
stream.cancelled = false
return offset
}
info.pointee.size_fn = { opaque in
cookie(opaque)?.size ?? Int64(MPV_ERROR_GENERIC.rawValue)
}
// The flag 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 (B16).
info.pointee.cancel_fn = { opaque in
guard let stream = cookie(opaque) else { return }
stream.cancelled = true
pstr_android_stream_cancel(stream.handle)
}
// The one moment defined to be after mpv's last read of this file, so the
// Rust stream is released here and nowhere else (B15/B28).
info.pointee.close_fn = { opaque in
guard let opaque else { return }
let stream = Unmanaged<StreamCookie>.fromOpaque(opaque).takeRetainedValue()
pstr_android_stream_release(stream.handle)
}
return 0
}
+57
View File
@@ -0,0 +1,57 @@
# XcodeGen spec for the iOS client. The .xcodeproj and Info.plist are generated
# from this by scripts/build-ios.sh and are not committed.
name: ProtonStream
options:
bundleIdPrefix: io.narl
deploymentTarget:
iOS: "17.0"
packages:
MPVKit:
url: https://github.com/mpvkit/MPVKit
exactVersion: 1.0.0
targets:
ProtonStream:
type: application
platform: iOS
sources:
- path: ProtonStream
# UniFFI output, written by `scripts/build-ios.sh bindings`.
- path: build/generated/PstrBridge.swift
dependencies:
# The GPL build is the one with libass and every demuxer; the app is
# sideloaded, never distributed through the App Store.
- package: MPVKit
product: MPVKit-GPL
- sdk: Security.framework
- sdk: SystemConfiguration.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream
MARKETING_VERSION: ${PSTR_VERSION}
CURRENT_PROJECT_VERSION: ${PSTR_BUILD}
SWIFT_VERSION: "5.0"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h
HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated
LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/../target/aarch64-apple-ios/release
OTHER_LDFLAGS: -lpstr_android
info:
path: ProtonStream/Info.plist
properties:
CFBundleDisplayName: Proton Stream
LSRequiresIPhoneOS: true
ITSAppUsesNonExemptEncryption: false
UIBackgroundModes: [audio]
UILaunchScreen: {}
UIRequiresFullScreen: false
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=0.4.0 pkgver=0.5.0
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Build the iOS client as an unsigned .ipa for SideStore / AltStore.
#
# SideStore re-signs whatever it installs with the viewer's own Apple ID, so
# nothing here touches a certificate or a provisioning profile. Needs macOS with
# Xcode, XcodeGen and the aarch64-apple-ios Rust target.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
ios_dir="$repo_root/ios"
generated="$ios_dir/build/generated"
target=aarch64-apple-ios
usage() {
echo "usage: $0 {rust|bindings|ipa|all}"
echo
echo " rust static libpstr_android.a for $target"
echo " bindings Swift bindings into ios/build/generated"
echo " ipa xcodegen + xcodebuild, then dist/ProtonStream-<version>.ipa"
echo " all all three, in that order"
}
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
[[ "$(uname -s)" == Darwin ]] || die "the iOS build needs macOS and Xcode"
# Same guard as scripts/build.sh: without the sibling checkout the resolve fails
# a hundred lines deep in cargo output.
if grep -q '^\[patch\.crates-io\]' "$repo_root/Cargo.toml" && [[ ! -d "$repo_root/../proton-sdk-rs" ]]; then
die "Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing."
fi
version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} found && /^version = /{gsub(/[" ]/, "", $3); print $3; exit}' "$repo_root/Cargo.toml")"
[[ -n "$version" ]] || die "no [workspace.package] version in Cargo.toml"
build_rust() {
log "cargo rustc -p pstr-android --target $target (staticlib)"
# The crate stays `cdylib, rlib` for Android; the archive iOS links is asked
# for here instead of being built on every other platform too.
( cd "$repo_root" && IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo rustc -p pstr-android --lib \
--release --locked --target "$target" --crate-type staticlib )
}
build_bindings() {
log "uniffi-bindgen --language swift"
# Library mode reads the interface out of a built library, and the host
# dylib is the one the bindgen binary can open.
( cd "$repo_root" && cargo build -p pstr-android --lib --locked )
rm -rf "$generated"
( cd "$repo_root" && cargo run -p pstr-android --features bindgen --bin uniffi-bindgen --locked -- \
generate --library target/debug/libpstr_android.dylib --language swift \
--no-format --out-dir "$generated" )
[[ -f "$generated/PstrBridge.swift" ]] || die "bindgen wrote no PstrBridge.swift"
}
build_ipa() {
command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)"
[[ -f "$repo_root/target/$target/release/libpstr_android.a" ]] || die "run '$0 rust' first"
[[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first"
log "xcodegen ($version)"
( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet )
log "xcodebuild (unsigned)"
xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \
-configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \
-derivedDataPath "$ios_dir/build/DerivedData" \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \
build
local app="$ios_dir/build/DerivedData/Build/Products/Release-iphoneos/ProtonStream.app"
[[ -d "$app" ]] || die "xcodebuild produced no ProtonStream.app"
local stage="$ios_dir/build/ipa"
rm -rf "$stage"
mkdir -p "$stage/Payload" "$repo_root/dist"
cp -R "$app" "$stage/Payload/"
local ipa="$repo_root/dist/ProtonStream-$version.ipa"
rm -f "$ipa"
( cd "$stage" && zip -qry "$ipa" Payload )
log "wrote $ipa"
}
case "${1:-}" in
rust) build_rust ;;
bindings) build_bindings ;;
ipa) build_ipa ;;
all) build_rust; build_bindings; build_ipa ;;
*) usage >&2; exit 2 ;;
esac