mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6518aef5ee | ||
|
|
9037e6c2d5 | ||
|
|
d3325843a9 | ||
|
|
79c3f6885d | ||
|
|
a432a93cf9 | ||
|
|
935a985589 | ||
|
|
7d03736954 | ||
|
|
53a1d0afda | ||
|
|
959547cdc8 | ||
|
|
c60ffc7ab1 | ||
|
|
c5808a5e50 | ||
|
|
41d1fbf073 | ||
|
|
ff362d0d08 | ||
|
|
4027ab5fb3 | ||
|
|
c5adcddb1e | ||
|
|
8f78f5c1b1 | ||
|
|
6f28a9c377 | ||
|
|
d3c5f071fb | ||
|
|
ad24557588 |
No files matched your search
Generated
+159
-14
@@ -609,6 +609,12 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1029,6 +1035,36 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -1136,6 +1172,19 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "core-graphics"
|
||||
version = "0.23.2"
|
||||
@@ -1145,7 +1194,7 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1377,6 +1426,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dbus-secret-service"
|
||||
version = "4.1.0"
|
||||
@@ -2024,6 +2082,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2031,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2045,6 +2112,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -2403,6 +2476,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -2592,6 +2684,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -3163,6 +3256,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -3483,6 +3585,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc-sys"
|
||||
version = "0.3.5"
|
||||
@@ -4202,9 +4313,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.4.0"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b162681255e296b48ef7261cad899ffb384756e866a1c6d7b7c791cbbc8af3ac"
|
||||
checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -4229,9 +4340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.4.0"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32d9b94ec901edb0e849238ea833121f2fb173308b4927b8aa6b157643fa0a73"
|
||||
checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
@@ -4262,7 +4373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni 0.21.1",
|
||||
@@ -4281,9 +4392,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dbus",
|
||||
"directories",
|
||||
"eframe",
|
||||
"egui",
|
||||
"futures",
|
||||
@@ -4293,17 +4406,20 @@ dependencies = [
|
||||
"pstr-meta",
|
||||
"pstr-player",
|
||||
"pstr-stream",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"souvlaki",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4321,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -4339,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -4352,7 +4468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
@@ -4367,7 +4483,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
@@ -4647,6 +4763,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -5401,6 +5518,24 @@ dependencies = [
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
@@ -5644,6 +5779,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -6539,6 +6675,15 @@ dependencies = [
|
||||
"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-core"
|
||||
version = "0.62.2"
|
||||
@@ -6911,7 +7056,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"core-graphics 0.23.2",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
"js-sys",
|
||||
|
||||
+12
-3
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
opt-level = 2
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.4.0" }
|
||||
proton-drive-rs = { version = "0.4.0" }
|
||||
proton-sdk = { version = "0.6.3" }
|
||||
proton-drive-rs = { version = "0.6.3" }
|
||||
|
||||
# Internal crates.
|
||||
pstr-core = { path = "crates/pstr-core" }
|
||||
@@ -84,6 +84,15 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
||||
# behind a poisoned lock for the rest of the session. Same reason
|
||||
# `proton-drive-linux` uses it.
|
||||
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"
|
||||
rpassword = "7"
|
||||
libloading = "0.8"
|
||||
lru = "0.12"
|
||||
|
||||
@@ -67,7 +67,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. |
|
||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||
| 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. |
|
||||
| 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,8 +103,12 @@ 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
|
||||
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.
|
||||
- The block cache holds decrypted content. It lives in your cache directory and
|
||||
can be deleted at any time the app is not running.
|
||||
- The block cache holds decrypted content. It lives in your cache directory,
|
||||
can be deleted at any time the app is not running, and can be sized or
|
||||
emptied from Settings → Storage.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1624,6 +1624,7 @@ private fun FlavorChoice.label() = when (this) {
|
||||
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
|
||||
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
|
||||
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
||||
FlavorChoice.PERSONA5 -> "Persona 5"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,6 +152,7 @@ pub enum FlavorChoice {
|
||||
Frappe,
|
||||
Macchiato,
|
||||
Mocha,
|
||||
Persona5,
|
||||
}
|
||||
|
||||
/// The one strong colour. The shared `pstr_core::appearance::Accent`.
|
||||
@@ -165,6 +166,7 @@ pub enum AccentChoice {
|
||||
Blue,
|
||||
Teal,
|
||||
Peach,
|
||||
Red,
|
||||
}
|
||||
|
||||
/// What the viewer chose, stored where the desktop client reads it too.
|
||||
@@ -1125,13 +1127,27 @@ impl AndroidEngine {
|
||||
title_key: String,
|
||||
preferences: TrackPreferencesRecord,
|
||||
) -> 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 {
|
||||
audio_language: normalized_language(preferences.audio_language),
|
||||
subtitle_language: normalized_language(preferences.subtitle_language),
|
||||
audio_title: existing
|
||||
.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,
|
||||
};
|
||||
self.catalog
|
||||
.lock()
|
||||
catalog
|
||||
.set_title_track_prefs(&title_key, &preferences)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
@@ -2348,6 +2364,7 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
|
||||
Flavor::Frappe => FlavorChoice::Frappe,
|
||||
Flavor::Macchiato => FlavorChoice::Macchiato,
|
||||
Flavor::Mocha => FlavorChoice::Mocha,
|
||||
Flavor::Persona5 => FlavorChoice::Persona5,
|
||||
},
|
||||
accent: match appearance.accent {
|
||||
Accent::Mauve => AccentChoice::Mauve,
|
||||
@@ -2358,6 +2375,7 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
|
||||
Accent::Blue => AccentChoice::Blue,
|
||||
Accent::Teal => AccentChoice::Teal,
|
||||
Accent::Peach => AccentChoice::Peach,
|
||||
Accent::Red => AccentChoice::Red,
|
||||
},
|
||||
gradients: appearance.gradients,
|
||||
}
|
||||
@@ -2371,6 +2389,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||
FlavorChoice::Frappe => Flavor::Frappe,
|
||||
FlavorChoice::Macchiato => Flavor::Macchiato,
|
||||
FlavorChoice::Mocha => Flavor::Mocha,
|
||||
FlavorChoice::Persona5 => Flavor::Persona5,
|
||||
},
|
||||
accent: match record.accent {
|
||||
AccentChoice::Mauve => Accent::Mauve,
|
||||
@@ -2381,6 +2400,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||
AccentChoice::Blue => Accent::Blue,
|
||||
AccentChoice::Teal => Accent::Teal,
|
||||
AccentChoice::Peach => Accent::Peach,
|
||||
AccentChoice::Red => Accent::Red,
|
||||
},
|
||||
gradients: record.gradients,
|
||||
}
|
||||
|
||||
@@ -29,3 +29,12 @@ sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
directories.workspace = true
|
||||
souvlaki.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dbus.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { workspace = true, features = ["Win32_System_Power"] }
|
||||
raw-window-handle = "0.6"
|
||||
@@ -92,7 +92,12 @@ impl Harness {
|
||||
.context("eframe was built without a glow context")?;
|
||||
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
||||
// 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 {
|
||||
player,
|
||||
|
||||
+1070
-112
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
+738
-195
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,14 @@
|
||||
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
|
||||
|
||||
pub mod app;
|
||||
pub mod desktop_prefs;
|
||||
pub mod engine;
|
||||
pub mod inhibit;
|
||||
pub mod media;
|
||||
pub mod notify;
|
||||
pub mod pacing;
|
||||
pub mod playback;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub mod video;
|
||||
pub mod window;
|
||||
@@ -31,14 +31,20 @@ fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
let window = pstr_app::window::WindowState::load(&dirs.window_file());
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.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_app_id("io.narl.proton-stream"),
|
||||
..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(
|
||||
"proton-stream",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! 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 only, over `org.freedesktop.Notifications`. Windows wants an
|
||||
//! application id registered by an installer before a toast will show, and a
|
||||
//! notification that silently never appears is worse than none.
|
||||
|
||||
/// 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(target_os = "linux")]
|
||||
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
||||
}
|
||||
|
||||
impl Notifier {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
orders: linux::spawn(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(orders) = &self.orders {
|
||||
let _ = orders.send((summary.into(), body.into()));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = (summary.into(), body.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Notifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +110,16 @@ impl PlaybackTarget {
|
||||
|
||||
/// The line under the title while this plays: the numbering, and the
|
||||
/// 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 {
|
||||
match &self.episode_name {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Command {
|
||||
TogglePause,
|
||||
SeekBy(f64),
|
||||
@@ -134,6 +144,13 @@ pub enum Command {
|
||||
/// every file it loads afterwards, so picking Japanese audio for one
|
||||
/// episode picks it for the next one too.
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -162,6 +179,11 @@ pub struct Playback {
|
||||
/// 0–100, as mpv has it.
|
||||
pub volume: f64,
|
||||
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.
|
||||
pub tracks: Vec<Track>,
|
||||
/// 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
|
||||
/// worth showing a spinner for on a link this slow.
|
||||
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 {
|
||||
@@ -196,10 +222,9 @@ impl Playback {
|
||||
target: PlaybackTarget,
|
||||
stream: VideoStream,
|
||||
gl: Option<&Arc<glow::Context>>,
|
||||
ctx: &egui::Context,
|
||||
) -> Result<Self, String> {
|
||||
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 id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -234,6 +259,8 @@ impl Playback {
|
||||
paused: false,
|
||||
volume: prefs.volume,
|
||||
muted: prefs.muted,
|
||||
subtitle_position: 100.0,
|
||||
speed: prefs.speed,
|
||||
tracks: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
roles: Vec::new(),
|
||||
@@ -242,10 +269,30 @@ impl Playback {
|
||||
duration: None,
|
||||
loaded: false,
|
||||
seeking: false,
|
||||
buffered: None,
|
||||
buffering: false,
|
||||
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.
|
||||
pub fn is_embedded(&self) -> bool {
|
||||
self.video.is_some()
|
||||
@@ -329,6 +376,8 @@ impl Playback {
|
||||
PlayerEvent::Paused(paused) => self.paused = *paused,
|
||||
PlayerEvent::Seek => self.seeking = true,
|
||||
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,
|
||||
prefs: &PlaybackPrefs,
|
||||
gl: Option<&Arc<glow::Context>>,
|
||||
ctx: &egui::Context,
|
||||
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
|
||||
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.
|
||||
on_screen_controller: false,
|
||||
default_keybindings: false,
|
||||
hardware_decoding: engine.desktop_prefs().hardware_decoding,
|
||||
..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
|
||||
// context current, and the surface is dropped from the same
|
||||
// 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))),
|
||||
Err(error) => {
|
||||
tracing::warn!("embed the video: {error}; falling back to a window");
|
||||
@@ -406,6 +459,7 @@ fn build_player(
|
||||
|
||||
let config = PlayerConfig {
|
||||
window_title: title,
|
||||
hardware_decoding: engine.desktop_prefs().hardware_decoding,
|
||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||
};
|
||||
let player = Player::new(engine.runtime().clone(), config)
|
||||
@@ -428,6 +482,9 @@ fn from_prefs(
|
||||
.and_then(|p| p.subtitle_language.clone())
|
||||
.or_else(|| prefs.subtitle_language.clone()),
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -487,8 +544,12 @@ fn run(
|
||||
PlayerEvent::FileLoaded => {
|
||||
duration = player.duration();
|
||||
// The first moment there is a track list to read: mpv has
|
||||
// demuxed the file and made its own default selection, which
|
||||
// is what the menus have to open showing.
|
||||
// demuxed the file and made its own default selection. Put the
|
||||
// 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);
|
||||
// Only now is there a timeline to seek within.
|
||||
if !resumed && let Some(at) = target.resume_at {
|
||||
@@ -565,6 +626,9 @@ fn apply_command(
|
||||
Command::SeekTo(seconds) => player.seek_to(seconds),
|
||||
Command::SetVolume(volume) => player.set_volume(volume),
|
||||
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::Stop => {
|
||||
let _ = player.quit();
|
||||
@@ -611,6 +675,44 @@ fn select_track(
|
||||
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.
|
||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||
engine.emit(Event::Player {
|
||||
|
||||
@@ -99,7 +99,21 @@ impl Role {
|
||||
/// Install a real CJK fallback before the default egui fonts. egui ships a
|
||||
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
|
||||
/// CJK families. Native desktops already provide one, so use it when present.
|
||||
///
|
||||
/// 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.
|
||||
pub fn install_font_fallbacks(ctx: &egui::Context) {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -116,6 +130,7 @@ pub fn install_font_fallbacks(ctx: &egui::Context) {
|
||||
fonts.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -358,6 +373,36 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
|
||||
painter.add(textured(rect, corner_radius, &texture));
|
||||
}
|
||||
|
||||
/// A rounded rectangle running from one colour to another, at `opacity`.
|
||||
///
|
||||
/// For colours that are not the palette's — a title's own, on a tile with no
|
||||
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
|
||||
pub fn ramp_fill(
|
||||
painter: &egui::Painter,
|
||||
rect: Rect,
|
||||
corner_radius: CornerRadius,
|
||||
(from, to): (Color32, Color32),
|
||||
direction: Direction,
|
||||
opacity: f32,
|
||||
) {
|
||||
let tint = Color32::WHITE.gamma_multiply(opacity);
|
||||
if !ramps_on() {
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
corner_radius,
|
||||
mix(from, to, 0.5).gamma_multiply(opacity),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let texture = ramp(painter.ctx(), from, to, direction);
|
||||
painter.add(
|
||||
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
|
||||
texture.id(),
|
||||
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A ramp from `color` to nothing, for laying artwork into the page.
|
||||
///
|
||||
/// The other ramps here run between two opaque colours; this one runs down its
|
||||
|
||||
+196
-134
@@ -5,62 +5,112 @@ use crate::engine::{DownloadItem, DownloadState};
|
||||
use crate::{theme, ui};
|
||||
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
while start < downloads.len() {
|
||||
let title_key = &downloads[start].target.title_key;
|
||||
let title = &downloads[start].target.title_name;
|
||||
let end = downloads[start..]
|
||||
.iter()
|
||||
.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();
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.set_max_width(960.0);
|
||||
summary(ui, downloads);
|
||||
ui.add_space(12.0);
|
||||
|
||||
ui.label(theme::Role::Section.rich(title).strong());
|
||||
let totals_known = group.iter().all(|item| item.total > 0);
|
||||
ui.horizontal(|ui| {
|
||||
if totals_known {
|
||||
ui.add(
|
||||
egui::ProgressBar::new(fraction(downloaded, total))
|
||||
.desired_width(260.0)
|
||||
.show_percentage(),
|
||||
);
|
||||
} else {
|
||||
ui.add(egui::Spinner::new().size(16.0));
|
||||
ui.label(ui::muted("Calculating size…"));
|
||||
let mut start = 0;
|
||||
while start < downloads.len() {
|
||||
let title_key = &downloads[start].target.title_key;
|
||||
let end = downloads[start..]
|
||||
.iter()
|
||||
.position(|item| item.target.title_key != *title_key)
|
||||
.map_or(downloads.len(), |offset| start + offset);
|
||||
group(ui, &downloads[start..end], actions);
|
||||
ui.add_space(14.0);
|
||||
start = end;
|
||||
}
|
||||
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(6.0);
|
||||
for item in group {
|
||||
row(ui, item, actions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +118,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(6))
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
@@ -78,85 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
item.target.subtitle.as_str()
|
||||
};
|
||||
ui.add_sized(
|
||||
[180.0, 18.0],
|
||||
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
|
||||
[72.0, 18.0],
|
||||
egui::Label::new(egui::RichText::new(caption).monospace()).truncate(),
|
||||
)
|
||||
.on_hover_text(&item.target.name);
|
||||
|
||||
let status = match &item.state {
|
||||
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}"),
|
||||
};
|
||||
let (status, colour) = status(item);
|
||||
ui.add_sized(
|
||||
[240.0, 18.0],
|
||||
egui::Label::new(ui::muted(status)).truncate(),
|
||||
[220.0, 18.0],
|
||||
egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(),
|
||||
);
|
||||
|
||||
if item.total > 0 {
|
||||
ui.add(
|
||||
egui::ProgressBar::new(item.percent())
|
||||
.desired_width(140.0)
|
||||
.show_percentage(),
|
||||
);
|
||||
ui.label(ui::muted(format!(
|
||||
"{} / {}",
|
||||
bytes(item.downloaded),
|
||||
bytes(item.total)
|
||||
)));
|
||||
}
|
||||
|
||||
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.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
buttons(ui, item, actions);
|
||||
if item.total > 0 && item.state != DownloadState::Completed {
|
||||
ui.add_space(8.0);
|
||||
ui.label(ui::muted(format!(
|
||||
"{} / {}",
|
||||
size(item.downloaded),
|
||||
size(item.total)
|
||||
)));
|
||||
let width = (ui.available_width() - 8.0).clamp(40.0, 320.0);
|
||||
ui::progress_bar(ui, item.percent(), width);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -167,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(value: u64) -> String {
|
||||
const KIB: f64 = 1024.0;
|
||||
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")
|
||||
}
|
||||
fn size(bytes: u64) -> String {
|
||||
ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX))
|
||||
}
|
||||
@@ -2,30 +2,54 @@
|
||||
|
||||
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::ui::Art;
|
||||
use crate::ui::{self, Card};
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
library: &Library,
|
||||
search: &str,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
/// What the page lists, and what it was asked for.
|
||||
pub struct Shelves<'a> {
|
||||
pub library: &'a Library,
|
||||
/// The search and the shelf, already worked out — see [`LibraryView`].
|
||||
pub view: &'a LibraryView,
|
||||
/// 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() {
|
||||
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();
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
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(18.0);
|
||||
}
|
||||
let resumable = pick(&view.resumable);
|
||||
if !resumable.is_empty() {
|
||||
ui::section(ui, "Continue watching");
|
||||
continue_row(ui, art, &resumable, actions);
|
||||
@@ -33,22 +57,32 @@ pub fn show(
|
||||
}
|
||||
}
|
||||
|
||||
ui::section(
|
||||
ui,
|
||||
&if searching {
|
||||
format!(
|
||||
"{} matching {:?}",
|
||||
plural(matches.len(), "title"),
|
||||
search.trim()
|
||||
)
|
||||
} else {
|
||||
plural(matches.len(), "title")
|
||||
},
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui::section(
|
||||
ui,
|
||||
&if searching {
|
||||
format!(
|
||||
"{} matching {:?}",
|
||||
plural(matches.len(), "title"),
|
||||
search.trim()
|
||||
)
|
||||
} 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(6.0);
|
||||
|
||||
if matches.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,6 +90,220 @@ 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(12);
|
||||
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(), 0.3);
|
||||
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}"),
|
||||
(Some(_), None) => "▶ Resume".to_owned(),
|
||||
(None, _) => "▶ Play".to_owned(),
|
||||
};
|
||||
if ui::accent_button(ui, &label).clicked() {
|
||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if ui.button("More info").clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
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(8.0);
|
||||
}
|
||||
let mut facts = vec![subtitle(title)];
|
||||
if let Some(found) = &found {
|
||||
if let Some(rating) = found.rating {
|
||||
facts.push(format!("★ {rating:.1}"));
|
||||
}
|
||||
facts.extend(found.genres.iter().take(3).cloned());
|
||||
}
|
||||
ui.label(ui::muted(facts.join(" · ")));
|
||||
ui.add_space(2.0);
|
||||
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(10.0);
|
||||
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.
|
||||
fn continue_row(
|
||||
ui: &mut egui::Ui,
|
||||
@@ -85,7 +333,7 @@ fn continue_row(
|
||||
})
|
||||
.unwrap_or_else(|| episode.label());
|
||||
|
||||
let clicked = ui::card(
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: art.of(title),
|
||||
@@ -97,10 +345,10 @@ fn continue_row(
|
||||
// to reach, so nothing to flex to.
|
||||
width: theme::CARD_WIDTH,
|
||||
},
|
||||
)
|
||||
.clicked();
|
||||
);
|
||||
tile_menu(&response, title, true, actions);
|
||||
|
||||
if clicked {
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
}
|
||||
@@ -111,7 +359,22 @@ fn continue_row(
|
||||
/// Every title, wrapped to the window.
|
||||
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
||||
let grid = ui::columns(ui.available_width());
|
||||
let row_height = ui::card_height(grid.width);
|
||||
for row in titles.chunks(grid.columns) {
|
||||
// A row scrolled out of view is only its height. Laying it out anyway
|
||||
// costs a subtitle and an artwork lookup per card per frame, and while
|
||||
// a film plays under the transport bar a frame is every frame.
|
||||
let slot = egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
egui::vec2(ui.available_width(), row_height),
|
||||
);
|
||||
if !ui.is_rect_visible(slot) {
|
||||
// The spacing `horizontal` would have added after itself, too, or
|
||||
// the page grows and shrinks under the scroll bar as rows come and
|
||||
// go.
|
||||
ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP);
|
||||
continue;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
// The gap the width was divided around, so what is drawn matches
|
||||
// what was measured. egui's default item spacing is narrower, and
|
||||
@@ -119,7 +382,7 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
||||
// the left edge.
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for title in row {
|
||||
let clicked = ui::card(
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: art.of(title),
|
||||
@@ -131,10 +394,10 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
||||
badge: None,
|
||||
width: grid.width,
|
||||
},
|
||||
)
|
||||
.clicked();
|
||||
);
|
||||
tile_menu(&response, title, false, actions);
|
||||
|
||||
if clicked {
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||
}
|
||||
}
|
||||
@@ -143,6 +406,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
|
||||
/// left.
|
||||
///
|
||||
@@ -168,6 +505,34 @@ 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(8), theme::card());
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::CARD_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(120.0);
|
||||
@@ -225,6 +590,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]
|
||||
fn plurals_read_naturally() {
|
||||
assert_eq!(plural(1, "title"), "1 title");
|
||||
|
||||
@@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
|
||||
.or_else(|| found.backdrop_url.clone())?;
|
||||
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
|
||||
let engine = art.engine;
|
||||
let requested = key.clone();
|
||||
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
|
||||
|
||||
+288
-37
@@ -9,8 +9,11 @@ pub mod matcher;
|
||||
pub mod player;
|
||||
pub mod settings;
|
||||
pub mod shares;
|
||||
pub mod shortcuts;
|
||||
pub mod title;
|
||||
pub mod toast;
|
||||
pub mod transport;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -48,6 +51,28 @@ impl Art<'_> {
|
||||
.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.
|
||||
///
|
||||
/// Provider artwork first, then Proton's still, then nothing. That order is
|
||||
@@ -62,12 +87,9 @@ impl Art<'_> {
|
||||
.and_then(|metadata| metadata.tile_art())
|
||||
{
|
||||
let engine = self.engine;
|
||||
let key = title.key.clone();
|
||||
let url = url.to_string();
|
||||
if let Some(texture) = self
|
||||
.posters
|
||||
.texture(title.key.clone(), || engine.request_poster(key, url))
|
||||
{
|
||||
if let Some(texture) = self.posters.texture(&title.key, || {
|
||||
engine.request_poster(title.key.clone(), url.to_string())
|
||||
}) {
|
||||
return Some((texture, shape));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +98,7 @@ impl Art<'_> {
|
||||
let engine = self.engine;
|
||||
let texture = self
|
||||
.thumbs
|
||||
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
|
||||
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))?;
|
||||
Some((texture, ArtShape::Landscape))
|
||||
}
|
||||
}
|
||||
@@ -95,6 +117,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.
|
||||
pub fn format_size(bytes: i64) -> String {
|
||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||
@@ -133,6 +161,184 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
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, 0.12);
|
||||
|
||||
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(3);
|
||||
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(6.0);
|
||||
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(10)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(420.0);
|
||||
ui.label(
|
||||
theme::Role::Heading
|
||||
.rich(heading)
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if danger_button(ui, verb).clicked() {
|
||||
answer = Answer::Confirmed;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
answer = Answer::Declined;
|
||||
}
|
||||
});
|
||||
});
|
||||
if answer == Answer::Pending && modal.should_close() {
|
||||
answer = Answer::Declined;
|
||||
}
|
||||
answer
|
||||
}
|
||||
|
||||
/// What [`filled`] paints behind its label.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Fill {
|
||||
@@ -281,6 +487,17 @@ pub struct Card<'a> {
|
||||
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.
|
||||
///
|
||||
/// A landscape picture is cropped to fill: a grid of differently-shaped black
|
||||
@@ -292,14 +509,16 @@ pub struct Card<'a> {
|
||||
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
let width = card.width;
|
||||
let image_height = (width * theme::CARD_ASPECT).round();
|
||||
// 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.
|
||||
let text_height = 54.0;
|
||||
let text_height = CARD_TEXT_HEIGHT;
|
||||
let radius = CornerRadius::same(8);
|
||||
|
||||
let (rect, response) =
|
||||
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) {
|
||||
return response;
|
||||
}
|
||||
@@ -309,9 +528,13 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
// ride it, so they arrive together rather than each snapping on its own.
|
||||
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||
// the frame rate — and it needs no state kept here.
|
||||
let hover =
|
||||
ui.ctx()
|
||||
.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12);
|
||||
// Focus lights a tile the way the pointer does, so the keyboard can be
|
||||
// followed across the grid.
|
||||
let hover = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("hover"),
|
||||
response.hovered() || response.has_focus(),
|
||||
0.12,
|
||||
);
|
||||
// The picture arrives whenever its download finishes, several frames after
|
||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||
// letters popping into stills into one settle.
|
||||
@@ -328,13 +551,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
|
||||
// art fades in, and it is the whole tile when there is no art to come.
|
||||
if art_in < 1.0 {
|
||||
painter.text(
|
||||
image_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
initials(card.name),
|
||||
egui::FontId::proportional(28.0),
|
||||
theme::muted().gamma_multiply(1.0 - art_in),
|
||||
);
|
||||
placeholder(painter, image_rect, card.name, 1.0 - art_in);
|
||||
}
|
||||
|
||||
// White because a textured rect *multiplies* its fill by the texture; the
|
||||
@@ -470,7 +687,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
||||
}
|
||||
@@ -485,14 +702,55 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
||||
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
|
||||
}
|
||||
|
||||
/// Up to two letters, for a tile with no picture.
|
||||
fn initials(name: &str) -> String {
|
||||
name.split_whitespace()
|
||||
.filter_map(|word| word.chars().next())
|
||||
.filter(|character| character.is_alphanumeric())
|
||||
.take(2)
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
/// What a tile with no picture shows: a gradient of its own, and its name.
|
||||
///
|
||||
/// Two grey letters on a grey card was the whole tile for any title nothing
|
||||
/// knew a picture for — a wall of them read as a page that had failed to load.
|
||||
/// The colours come from the name, so a title wears the same ones every time
|
||||
/// and two titles side by side rarely match.
|
||||
///
|
||||
/// 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(8),
|
||||
(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.
|
||||
@@ -567,13 +825,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initials_take_two_letters() {
|
||||
assert_eq!(initials("Cowboy Bebop"), "CB");
|
||||
assert_eq!(initials("Akira"), "A");
|
||||
assert_eq!(initials(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_count_never_reaches_zero() {
|
||||
assert_eq!(columns(0.0).columns, 1);
|
||||
|
||||
@@ -118,11 +118,21 @@ pub struct Chrome<'a> {
|
||||
pub struct UpNextCard {
|
||||
/// Seconds until the next episode starts.
|
||||
pub seconds: f64,
|
||||
/// The same, as a fraction of the whole countdown, for the ring.
|
||||
pub left: f32,
|
||||
/// Which episode that is.
|
||||
pub caption: String,
|
||||
/// Its still, once there is one.
|
||||
pub still: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn observe(&mut self, ctx: &egui::Context) {
|
||||
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
|
||||
@@ -200,6 +210,8 @@ pub fn show(
|
||||
|
||||
if !picture {
|
||||
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
|
||||
@@ -208,6 +220,11 @@ pub fn show(
|
||||
actions.push(Action::Player(Command::TogglePause));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -226,16 +243,31 @@ pub fn show(
|
||||
None => skip(ui, playback, rect, float_above, actions),
|
||||
}
|
||||
|
||||
if visible {
|
||||
overlay.chrome_height = chrome(
|
||||
ui,
|
||||
playback,
|
||||
neighbours,
|
||||
rect,
|
||||
overlay.chrome_height,
|
||||
actions,
|
||||
);
|
||||
} else {
|
||||
// Last frame's height, like everything else that has to clear the controls.
|
||||
playback.lift_subtitles(
|
||||
if visible { overlay.chrome_height } else { 0.0 },
|
||||
rect.height(),
|
||||
);
|
||||
|
||||
// Faded rather than switched, both ways: controls that blink out under a
|
||||
// reading eye are more distracting than the ones that were there.
|
||||
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, 0.18);
|
||||
if fade > 0.0 {
|
||||
overlay.chrome_height = ui
|
||||
.scope(|ui| {
|
||||
ui.multiply_opacity(fade);
|
||||
chrome(
|
||||
ui,
|
||||
playback,
|
||||
neighbours,
|
||||
rect,
|
||||
overlay.chrome_height,
|
||||
actions,
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
}
|
||||
if !visible {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
// 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.
|
||||
@@ -303,6 +335,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.
|
||||
///
|
||||
/// It keeps a way out, because opening a file over a public link can take
|
||||
@@ -428,60 +472,114 @@ fn up_next_card(
|
||||
bottom_margin: f32,
|
||||
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| {
|
||||
egui::Frame::new()
|
||||
.fill(Color32::from_black_alpha(215))
|
||||
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::symmetric(14, 12))
|
||||
.inner_margin(egui::Margin::same(12))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(size.x - 28.0);
|
||||
ui.set_width(size.x - 24.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich("Up next")
|
||||
.strong()
|
||||
.color(Color32::from_white_alpha(200)),
|
||||
// The still, with the countdown running round it: what is
|
||||
// coming and when, read in one glance.
|
||||
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
|
||||
let painter = ui.painter();
|
||||
let radius = CornerRadius::same(6);
|
||||
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| {
|
||||
// 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() {
|
||||
if response.on_hover_text("Play now").clicked() {
|
||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button("Watch till the end")
|
||||
.on_hover_text("Play this one out — nothing will be skipped")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::WatchToEnd);
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich("Up next")
|
||||
.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(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui::accent_button(ui, "Play now").clicked() {
|
||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
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.
|
||||
///
|
||||
/// The inset matches the controls' own padding rather than being its own
|
||||
@@ -610,28 +708,14 @@ fn chrome(
|
||||
}
|
||||
|
||||
/// The gradient behind the title strip, fading downwards out of the top edge.
|
||||
///
|
||||
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
|
||||
/// primitive, and at this size the banding is invisible.
|
||||
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
||||
const BANDS: usize = 10;
|
||||
let painter = ui.painter();
|
||||
let band = rect.height() / BANDS as f32;
|
||||
for index in 0..BANDS {
|
||||
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
|
||||
let strip = Rect::from_min_size(
|
||||
egui::pos2(rect.left(), rect.top() + band * index as f32),
|
||||
Vec2::new(rect.width(), band + 1.0),
|
||||
);
|
||||
painter.rect_filled(
|
||||
strip,
|
||||
CornerRadius::ZERO,
|
||||
// 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.
|
||||
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
|
||||
);
|
||||
}
|
||||
// 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.
|
||||
@@ -642,27 +726,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
||||
/// strip itself is one flat wash, and the ramp happens in the empty picture
|
||||
/// above it, where there is nothing to obscure.
|
||||
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
|
||||
const BANDS: usize = 12;
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(
|
||||
strip,
|
||||
CornerRadius::ZERO,
|
||||
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;
|
||||
for index in 0..BANDS {
|
||||
let fraction = (index + 1) as f32 / BANDS as f32;
|
||||
let rect = Rect::from_min_size(
|
||||
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
|
||||
Vec2::new(strip.width(), band + 1.0),
|
||||
);
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
CornerRadius::ZERO,
|
||||
// Squared again: the wash should meet the strip at full strength
|
||||
// and be gone within a finger's width of picture above it.
|
||||
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
|
||||
);
|
||||
/// Black over `rect`, at the alpha `curve` gives for each fraction of the way
|
||||
/// down it.
|
||||
///
|
||||
/// One mesh with a colour per row of vertices, which the GPU interpolates
|
||||
/// between. It replaced a stack of flat bands, and the bands were what showed:
|
||||
/// 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.
|
||||
fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
|
||||
/// Enough rows that the curve between them reads as a curve.
|
||||
const ROWS: u32 = 16;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for row in 0..=ROWS {
|
||||
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
|
||||
}
|
||||
+319
-150
@@ -9,6 +9,7 @@ use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
|
||||
@@ -20,9 +21,17 @@ use crate::ui;
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Prefs {
|
||||
pub autoplay: bool,
|
||||
pub auto_skip: bool,
|
||||
pub appearance: Appearance,
|
||||
pub desktop: DesktopPrefs,
|
||||
/// What the streaming cache holds, once the shares are open.
|
||||
pub cache_used: Option<u64>,
|
||||
}
|
||||
|
||||
/// The widest the settings column gets. Rows that run the width of a 2K window
|
||||
/// put a switch a whole screen away from what it switches.
|
||||
const COLUMN: f32 = 760.0;
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
@@ -33,13 +42,22 @@ pub fn show(
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
appearance_form(ui, prefs.appearance, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
playback_form(ui, prefs.autoplay, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
// Centred, so a wide window frames the column rather than leaving it
|
||||
// against one edge with the rest of the page empty.
|
||||
let width = ui.available_width().min(COLUMN);
|
||||
let margin = ((ui.available_width() - width) / 2.0).max(0.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(margin);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width);
|
||||
ui.add_space(8.0);
|
||||
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
|
||||
/// only changed a small square would be asking someone to imagine the result.
|
||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Appearance");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted("Palette"));
|
||||
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||
ui.add_space(8.0);
|
||||
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for flavor in Flavor::ALL {
|
||||
if ui
|
||||
.radio(appearance.flavor == flavor, flavor.label())
|
||||
if palette_card(ui, appearance, flavor)
|
||||
.on_hover_text(flavor.description())
|
||||
.clicked()
|
||||
&& appearance.flavor != flavor
|
||||
@@ -72,35 +85,114 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.label(ui::muted("Accent"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for accent in Accent::ALL {
|
||||
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
ui::widgets::settings_row(ui, "Accent", None, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
// Right to left, so reversed to read in the order they are listed.
|
||||
for accent in Accent::ALL.into_iter().rev() {
|
||||
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
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(), 0.10);
|
||||
let painter = ui.painter();
|
||||
let radius = egui::CornerRadius::same(8);
|
||||
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(3), 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(3), &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.
|
||||
@@ -109,23 +201,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.
|
||||
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
||||
let selected = appearance.accent == accent;
|
||||
let size = egui::Vec2::new(52.0, 26.0);
|
||||
let size = egui::Vec2::splat(26.0);
|
||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
let palette = theme::Palette::resolve(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
});
|
||||
let radius = egui::CornerRadius::same(6);
|
||||
let radius = egui::CornerRadius::same(13);
|
||||
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
||||
if selected {
|
||||
// A ring rather than a tick: the tick would have to be drawn in an
|
||||
// ink that reads on eight different fills.
|
||||
ui.painter().rect_stroke(
|
||||
rect.expand(2.0),
|
||||
egui::CornerRadius::same(8),
|
||||
ui.painter().circle_stroke(
|
||||
rect.center(),
|
||||
rect.width() / 2.0 + 3.0,
|
||||
egui::Stroke::new(2.0, theme::text()),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,34 +225,120 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
||||
.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
|
||||
/// itself, where a viewer can hear the result — only autoplay has no natural
|
||||
/// home there, because by the time it matters the episode is over.
|
||||
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Playback");
|
||||
/// Volume, audio language, subtitle language and speed are all set from the
|
||||
/// player itself, where a viewer can hear the result.
|
||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Playback",
|
||||
Some(
|
||||
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
|
||||
),
|
||||
|ui| {
|
||||
let mut autoplay = prefs.autoplay;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Play the next episode",
|
||||
Some("When an episode ends, start the next one. Never after a file that failed."),
|
||||
|ui| {
|
||||
if ui::widgets::toggle(ui, &mut autoplay, "").changed() {
|
||||
actions.push(Action::SetAutoplay(autoplay));
|
||||
}
|
||||
},
|
||||
);
|
||||
let mut auto_skip = prefs.auto_skip;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Skip openings and credits",
|
||||
Some(
|
||||
"Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.",
|
||||
),
|
||||
|ui| {
|
||||
if ui::widgets::toggle(ui, &mut auto_skip, "").changed() {
|
||||
actions.push(Action::SetAutoSkip(auto_skip));
|
||||
}
|
||||
},
|
||||
);
|
||||
let mut desktop = prefs.desktop;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Hardware decoding",
|
||||
Some(
|
||||
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
|
||||
),
|
||||
|ui| {
|
||||
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
|
||||
actions.push(Action::SetDesktopPrefs(desktop));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
/// The streaming cache: how big it may get, and a way to empty it.
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Storage",
|
||||
Some(
|
||||
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
|
||||
),
|
||||
|ui| {
|
||||
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
|
||||
let used = match prefs.cache_used {
|
||||
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
|
||||
None => format!("Up to {budget}"),
|
||||
};
|
||||
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
|
||||
let choices: Vec<(u32, String)> = CACHE_CHOICES
|
||||
.iter()
|
||||
.map(|&gib| (gib, format!("{gib} GiB")))
|
||||
.collect();
|
||||
let labels: Vec<(u32, &str)> = choices
|
||||
.iter()
|
||||
.map(|(gib, label)| (*gib, label.as_str()))
|
||||
.collect();
|
||||
if let Some(cache_gib) =
|
||||
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
|
||||
{
|
||||
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
|
||||
cache_gib,
|
||||
..prefs.desktop
|
||||
}));
|
||||
}
|
||||
});
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Clear the streaming cache",
|
||||
Some("Frees the space now. Whatever plays next is fetched again."),
|
||||
|ui| {
|
||||
if ui.button("Clear").clicked() {
|
||||
actions.push(Action::ClearStreamCache);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut enabled = autoplay;
|
||||
if ui
|
||||
.checkbox(&mut enabled, "Play the next episode automatically")
|
||||
.on_hover_text("Only when an episode reaches its end, never after a failure")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetAutoplay(enabled));
|
||||
}
|
||||
ui.label(ui::muted(
|
||||
"Volume, audio language and subtitles are set from the player, and remembered \
|
||||
from there.",
|
||||
));
|
||||
/// What this is, and which version.
|
||||
fn about(ui: &mut egui::Ui) {
|
||||
ui::widgets::settings_group(ui, "About", None, |ui| {
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"proton-stream",
|
||||
Some("Streams Proton Drive share links without an account or a download step."),
|
||||
|ui| {
|
||||
ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| {
|
||||
ui.label(ui::muted("press ?"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
||||
@@ -176,94 +353,86 @@ fn metadata_form(
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
ui::section(ui, "Posters and descriptions");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted(
|
||||
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
||||
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
ui.label(ui::muted(
|
||||
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
||||
without this every tile is a pair of initials. Turning it on sends the titles \
|
||||
in your library — not your files, and nothing about what you have watched — to \
|
||||
the provider you choose, over HTTPS, each time a new one appears.",
|
||||
));
|
||||
ui.add_space(10.0);
|
||||
));
|
||||
ui.add_space(10.0);
|
||||
|
||||
let mut changed = false;
|
||||
let mut enabled = settings.enabled;
|
||||
if ui
|
||||
.checkbox(&mut enabled, "Look up posters and descriptions")
|
||||
.changed()
|
||||
{
|
||||
settings.enabled = enabled;
|
||||
changed = true;
|
||||
let mut changed = false;
|
||||
let mut enabled = settings.enabled;
|
||||
if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
|
||||
settings.enabled = enabled;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if settings.enabled {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted("Provider"));
|
||||
for provider in ProviderId::ALL {
|
||||
if ui
|
||||
.radio(settings.provider == provider, provider.label())
|
||||
.on_hover_text(provider.description())
|
||||
.clicked()
|
||||
&& settings.provider != provider
|
||||
{
|
||||
settings.provider = provider;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if settings.enabled {
|
||||
if settings.provider.needs_api_key() {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted("Provider"));
|
||||
for provider in ProviderId::ALL {
|
||||
if ui
|
||||
.radio(settings.provider == provider, provider.label())
|
||||
.on_hover_text(provider.description())
|
||||
.clicked()
|
||||
&& settings.provider != provider
|
||||
{
|
||||
settings.provider = provider;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if settings.provider.needs_api_key() {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: settings.provider,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
));
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||
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 });
|
||||
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),
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.add_space(6.0);
|
||||
ui.label(ui::muted(
|
||||
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
||||
already stored.",
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
));
|
||||
}
|
||||
|
||||
if changed {
|
||||
actions.push(Action::SetMetadataConfig(settings.clone()));
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui::accent_button(ui, "Match the library").clicked() {
|
||||
actions.push(Action::MatchTitles { force: false });
|
||||
}
|
||||
if ui
|
||||
.button("Match everything again")
|
||||
.on_hover_text("Ask about every title, including ones already matched")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::MatchTitles { force: true });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.add_space(6.0);
|
||||
ui.label(ui::muted(
|
||||
"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(12.0);
|
||||
});
|
||||
}
|
||||
@@ -7,29 +7,43 @@
|
||||
//! typed.
|
||||
|
||||
use pstr_core::Share;
|
||||
use pstr_core::library::Library;
|
||||
|
||||
use crate::app::{Action, ShareForm};
|
||||
use crate::theme;
|
||||
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()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui::section(ui, "Shares");
|
||||
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 {
|
||||
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);
|
||||
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()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
@@ -39,12 +53,13 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&share.name).strong());
|
||||
let detail = if share.has_custom_password {
|
||||
format!("{} · custom password", share.id)
|
||||
} else {
|
||||
share.id.clone()
|
||||
};
|
||||
ui.label(ui::muted(detail));
|
||||
// What is in it, rather than what it is called inside the
|
||||
// app. The id is still a hover away for a bug report.
|
||||
let mut detail = ui::library::plural(titles, "title");
|
||||
if share.has_custom_password {
|
||||
detail.push_str(" · password protected");
|
||||
}
|
||||
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -64,7 +79,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
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.label(ui::muted(
|
||||
"Paste the whole link, including everything after the # — that part is the key that \
|
||||
@@ -88,15 +103,40 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.label(ui::muted("Link"));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
let paste = 60.0;
|
||||
let field = egui::Id::new("share-link");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.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(8.0);
|
||||
|
||||
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 {
|
||||
ui.add_space(6.0);
|
||||
ui.add(
|
||||
@@ -110,8 +150,14 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
||||
ui.add_space(12.0);
|
||||
let ready = !form.name.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(6.0);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.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()),
|
||||
});
|
||||
}
|
||||
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."));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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 – 4", "Library, 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"),
|
||||
("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(12)),
|
||||
)
|
||||
.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(12.0);
|
||||
ui.label(ui::muted(heading.to_uppercase()));
|
||||
ui.add_space(4.0);
|
||||
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()
|
||||
}
|
||||
+515
-215
@@ -30,10 +30,10 @@ pub fn show(
|
||||
return;
|
||||
};
|
||||
|
||||
if ui.button("Library").clicked() {
|
||||
if back_link(ui).clicked() {
|
||||
actions.push(Action::Goto(Page::Library));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.add_space(6.0);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
@@ -41,54 +41,142 @@ pub fn show(
|
||||
header(ui, art, title, &offline, actions);
|
||||
ui.add_space(18.0);
|
||||
|
||||
for (index, season) in title.seasons.iter().enumerate() {
|
||||
let open = index == 0 || title.seasons.len() == 1;
|
||||
egui::CollapsingHeader::new(
|
||||
theme::Role::Subhead
|
||||
.rich(format!(
|
||||
"{} · {}",
|
||||
season.label(),
|
||||
ui::library::plural(season.episodes.len(), "episode")
|
||||
))
|
||||
.strong(),
|
||||
)
|
||||
.id_salt(("season", index))
|
||||
.default_open(open)
|
||||
.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));
|
||||
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(),
|
||||
));
|
||||
}
|
||||
for episode in &season.episodes {
|
||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||
// One season at a time, picked from a row of pills, rather than
|
||||
// every season stacked as a collapsing header: a four-season show
|
||||
// was a page of headers to scroll past to reach the one being
|
||||
// watched. The one opened first is the one with the next episode.
|
||||
let picked_id = ui.id().with(("season", &title.key));
|
||||
let default = title
|
||||
.next_up()
|
||||
.and_then(|next| {
|
||||
title.seasons.iter().position(|season| {
|
||||
season
|
||||
.episodes
|
||||
.iter()
|
||||
.any(|episode| episode.node.link_id == next.node.link_id)
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut picked = ui
|
||||
.data(|data| data.get_temp::<usize>(picked_id))
|
||||
.unwrap_or(default)
|
||||
.min(title.seasons.len().saturating_sub(1));
|
||||
|
||||
let Some(season) = title.seasons.get(picked) else {
|
||||
return;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
if title.seasons.len() > 1 {
|
||||
let labels: Vec<String> =
|
||||
title.seasons.iter().map(|season| season.label()).collect();
|
||||
let choices: Vec<(usize, &str)> = labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, label)| (index, label.as_str()))
|
||||
.collect();
|
||||
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
|
||||
picked = index;
|
||||
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
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(10.0);
|
||||
|
||||
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(12.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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(10);
|
||||
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("← Library");
|
||||
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.
|
||||
fn header(
|
||||
ui: &mut egui::Ui,
|
||||
@@ -115,20 +203,32 @@ fn header(
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: &title.name,
|
||||
subtitle: String::new(),
|
||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: None,
|
||||
width: theme::CARD_WIDTH,
|
||||
},
|
||||
);
|
||||
match &picture {
|
||||
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
|
||||
// uses, it was a narrow strip of picture between two bars.
|
||||
Some((texture, pstr_core::metadata::ArtShape::Portrait)) => {
|
||||
poster(ui, texture, title.resume().and_then(|e| e.progress()));
|
||||
}
|
||||
_ => {
|
||||
ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: &title.name,
|
||||
subtitle: String::new(),
|
||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: None,
|
||||
width: theme::CARD_WIDTH,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.add_space(22.0);
|
||||
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());
|
||||
// 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
|
||||
@@ -149,9 +249,17 @@ fn header(
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(episode) = title.next_up() {
|
||||
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()) {
|
||||
(true, Some(numbering)) => format!("Resume {numbering}"),
|
||||
(true, None) => "Resume".to_string(),
|
||||
(true, Some(numbering)) => format!("Resume {numbering}{at}"),
|
||||
(true, None) => format!("Resume{at}"),
|
||||
(false, Some(numbering)) if title.kind == TitleKind::Series => {
|
||||
format!("Play {numbering}")
|
||||
}
|
||||
@@ -160,6 +268,19 @@ fn header(
|
||||
if ui::accent_button(ui, &label).clicked() {
|
||||
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 all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||
if all_offline {
|
||||
@@ -202,20 +323,6 @@ fn header(
|
||||
} else if all_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
|
||||
@@ -299,7 +406,10 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
||||
egui::epaint::RectShape::filled(
|
||||
rect,
|
||||
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(),
|
||||
);
|
||||
@@ -340,11 +450,50 @@ 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.
|
||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||
if !found.genres.is_empty() {
|
||||
ui.label(ui::muted(found.genres.join(" · ")));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
for genre in &found.genres {
|
||||
ui::widgets::chip(ui, genre);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
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 {
|
||||
ui.add_space(8.0);
|
||||
@@ -398,7 +547,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
||||
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(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
@@ -417,158 +570,305 @@ fn episode_row(
|
||||
)
|
||||
});
|
||||
let watched = episode.is_watched();
|
||||
egui::Frame::new()
|
||||
.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 row_id = ui.id().with(("episode", &episode.node.link_id));
|
||||
|
||||
let numbering = episode
|
||||
.numbering()
|
||||
.or_else(|| season.number.map(|number| format!("S{number:02}")))
|
||||
.unwrap_or_default();
|
||||
ui.add_sized(
|
||||
[66.0, 18.0],
|
||||
egui::Label::new(
|
||||
egui::RichText::new(numbering)
|
||||
.monospace()
|
||||
.color(theme::muted()),
|
||||
),
|
||||
);
|
||||
let frame = egui::Frame::new()
|
||||
.corner_radius(egui::CornerRadius::same(10))
|
||||
.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| {
|
||||
still(ui, art, title, episode, watched, actions);
|
||||
ui.add_space(6.0);
|
||||
|
||||
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 Immortal Legion" reads as an episode, and
|
||||
// "[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
|
||||
.as_ref()
|
||||
.and_then(|(name, _, _)| name.clone())
|
||||
.unwrap_or_else(|| episode.detail().to_string());
|
||||
let label = egui::RichText::new(name).color(if watched {
|
||||
theme::muted()
|
||||
} else {
|
||||
theme::text()
|
||||
});
|
||||
let hover = match &found {
|
||||
// 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()));
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
if !numbering.is_empty() {
|
||||
job.append(
|
||||
&format!("{numbering} "),
|
||||
0.0,
|
||||
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
|
||||
);
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut checked = watched;
|
||||
if ui
|
||||
.checkbox(&mut checked, "")
|
||||
.on_hover_text(if watched {
|
||||
"Mark unwatched"
|
||||
job.append(
|
||||
&name,
|
||||
0.0,
|
||||
egui::TextFormat::simple(
|
||||
theme::Role::Body.font(),
|
||||
if watched {
|
||||
theme::muted()
|
||||
} else {
|
||||
"Mark watched"
|
||||
})
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetWatched {
|
||||
share_id: episode.node.share_id.clone(),
|
||||
link_id: episode.node.link_id.clone(),
|
||||
watched: checked,
|
||||
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
||||
});
|
||||
}
|
||||
theme::text()
|
||||
},
|
||||
),
|
||||
);
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.overflow_character = Some('…');
|
||||
ui.add(egui::Label::new(job).truncate())
|
||||
.on_hover_text(&episode.node.name);
|
||||
|
||||
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 so on the row rather than only when a click on
|
||||
// it comes back with "has no content".
|
||||
Some(0) => {
|
||||
ui.label(theme::Role::Caption.rich("empty").color(theme::danger()))
|
||||
.on_hover_text(
|
||||
"The share reports no content for this file; it cannot be played.",
|
||||
);
|
||||
}
|
||||
Some(size) => {
|
||||
ui.label(ui::muted(ui::format_size(size)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(at) = episode.resume_at() {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(format!("resume {}", ui::format_time(at)))
|
||||
.color(theme::accent()),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted(details(
|
||||
episode,
|
||||
found.as_ref().and_then(|f| f.2.as_deref()),
|
||||
)));
|
||||
|
||||
if let Some((_, Some(overview), _)) = &found {
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
overview.clone(),
|
||||
theme::Role::Caption.font(),
|
||||
theme::muted(),
|
||||
text_width,
|
||||
);
|
||||
job.wrap.max_rows = 2;
|
||||
job.wrap.overflow_character = Some('…');
|
||||
ui.add(egui::Label::new(job)).on_hover_text(overview);
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.set_min_height(STILL.y);
|
||||
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(6.0);
|
||||
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(),
|
||||
0.10,
|
||||
);
|
||||
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
||||
prepared.paint(ui);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn still(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
title: &Title,
|
||||
episode: &Episode,
|
||||
watched: bool,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let (rect, response) = ui.allocate_exact_size(STILL, egui::Sense::click());
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return;
|
||||
}
|
||||
let radius = egui::CornerRadius::same(8);
|
||||
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(),
|
||||
0.12,
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 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 = 0.18;
|
||||
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 = ease(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 }
|
||||
}
|
||||
|
||||
fn ease(t: f32) -> f32 {
|
||||
1.0 - (1.0 - t).powi(3)
|
||||
}
|
||||
|
||||
/// 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(10))
|
||||
.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(4.0);
|
||||
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(2), 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");
|
||||
}
|
||||
}
|
||||
@@ -81,9 +81,9 @@ pub fn full(
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
if playback.seeking || !playback.loaded {
|
||||
if playback.seeking || playback.buffering || !playback.loaded {
|
||||
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"
|
||||
} else {
|
||||
"buffering"
|
||||
@@ -94,6 +94,8 @@ pub fn full(
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
track_menus(ui, playback, actions);
|
||||
ui.add_space(8.0);
|
||||
speed_menu(ui, playback, actions);
|
||||
ui.add_space(8.0);
|
||||
chapter_menu(ui, playback, actions);
|
||||
ui.add_space(8.0);
|
||||
volume(ui, playback, actions);
|
||||
@@ -102,82 +104,135 @@ pub fn full(
|
||||
}
|
||||
|
||||
/// 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(
|
||||
ui: &mut egui::Ui,
|
||||
playback: &Playback,
|
||||
picture: Option<egui::TextureId>,
|
||||
neighbours: Neighbours,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_min_height(ROW_HEIGHT_MINI);
|
||||
|
||||
// The way back to the picture. First, and a real button rather than a
|
||||
// label, because leaving the player page with Esc is otherwise a
|
||||
// one-way trip: playback carries on with nowhere to watch it.
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
||||
.fill(theme::card_hover())
|
||||
.corner_radius(CornerRadius::same(8)),
|
||||
)
|
||||
.on_hover_text("Show the picture again")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Goto(Page::Player));
|
||||
// The way back to the picture, first, because leaving the player page
|
||||
// with Esc is otherwise a one-way trip: playback carries on with
|
||||
// nowhere to watch it. The picture itself when there is one — it is
|
||||
// both the reminder that something is playing and the button.
|
||||
match picture {
|
||||
Some(texture) => {
|
||||
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
|
||||
let painter = ui.painter();
|
||||
let radius = CornerRadius::same(6);
|
||||
painter.rect_filled(rect, radius, Color32::BLACK);
|
||||
painter.add(
|
||||
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
|
||||
texture,
|
||||
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
),
|
||||
);
|
||||
let hover = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("hover"),
|
||||
response.hovered(),
|
||||
0.12,
|
||||
);
|
||||
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(8)),
|
||||
)
|
||||
.on_hover_text("Show the picture again")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Goto(Page::Player));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.vertical(|ui| {
|
||||
// Back to what is playing: after ten minutes of browsing, the bar
|
||||
// is the only thing that still knows which title this came from.
|
||||
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.horizontal(|ui| {
|
||||
ui.set_min_height(ROW_HEIGHT_MINI);
|
||||
|
||||
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(6.0);
|
||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
ui.vertical(|ui| {
|
||||
// Back to what is playing: after ten minutes of browsing, the bar
|
||||
// is the only thing that still knows which title this came from.
|
||||
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| {
|
||||
if ui.button("Stop").clicked() {
|
||||
actions.push(Action::Player(Command::Stop));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
});
|
||||
play_pause(ui, playback, actions, 34.0);
|
||||
step_button(
|
||||
ui,
|
||||
"⏮",
|
||||
"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.buffering || !playback.loaded {
|
||||
ui.add_space(6.0);
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
}
|
||||
});
|
||||
});
|
||||
play_pause(ui, playback, actions, 34.0);
|
||||
step_button(
|
||||
ui,
|
||||
"⏮",
|
||||
"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);
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
seek_bar(ui, playback, actions, 5.0);
|
||||
times(ui, playback, false);
|
||||
}
|
||||
|
||||
/// The one button whose position should never move.
|
||||
@@ -429,6 +484,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
|
||||
/// most releases means anime and not much else.
|
||||
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
@@ -470,66 +551,101 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||
|
||||
let hovered = response.hovered() || response.dragged();
|
||||
let track = Rect::from_center_size(
|
||||
rect.center(),
|
||||
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
|
||||
);
|
||||
let active = response.hovered() || response.dragged();
|
||||
// Grows rather than jumps when the pointer arrives.
|
||||
let grow = ui
|
||||
.ctx()
|
||||
.animate_bool_with_time(response.id.with("grow"), active, 0.12);
|
||||
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
|
||||
let radius = CornerRadius::same(3);
|
||||
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;
|
||||
played.set_width(track.width() * progress);
|
||||
// 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 file. A bar that only ever shows the first fifth of its own ramp
|
||||
// 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(
|
||||
egui::pos2(played.right(), track.center().y),
|
||||
if hovered { 8.0 } else { 6.0 },
|
||||
5.0 + 3.0 * grow,
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
// Seeking needs a duration: without one there is nothing for a fraction of
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Chapter marks, over the bar rather than under it: an opening is two
|
||||
// minutes of a twenty-four minute file, and the point of the mark is to be
|
||||
// able to aim just past it.
|
||||
// Chapter marks, cut into the bar rather than drawn on it: an opening is
|
||||
// two minutes of a twenty-four minute file, and the point of the mark is to
|
||||
// be able to aim just past it.
|
||||
for chapter in playback.chapters.iter().skip(1) {
|
||||
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
|
||||
let x = track.left() + track.width() * fraction;
|
||||
painter.rect_filled(
|
||||
Rect::from_min_size(
|
||||
egui::pos2(x - 1.0, track.top() - 1.0),
|
||||
Vec2::new(2.0, track.height() + 2.0),
|
||||
egui::pos2(x - 1.5, track.top() - 1.0),
|
||||
Vec2::new(3.0, track.height() + 2.0),
|
||||
),
|
||||
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
|
||||
// read-ahead and refetches, so a dragged bar that seeks continuously spends
|
||||
// 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())
|
||||
{
|
||||
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
|
||||
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
|
||||
}
|
||||
|
||||
if response.hovered()
|
||||
&& let Some(position) = response.hover_pos()
|
||||
{
|
||||
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
|
||||
let at = fraction * duration;
|
||||
if let (Some(fraction), true) = (pointer, active) {
|
||||
let at = f64::from(fraction) * duration;
|
||||
// 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
|
||||
// aim at a particular part of the bar.
|
||||
@@ -541,6 +657,25 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
||||
),
|
||||
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(6), Color32::from_black_alpha(215));
|
||||
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 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, 0.14);
|
||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
||||
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, 0.14);
|
||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
||||
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(4.0);
|
||||
ui.label(
|
||||
theme::Role::Subhead
|
||||
.rich(heading)
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
);
|
||||
if let Some(description) = description {
|
||||
ui.add_space(2.0);
|
||||
ui.label(crate::ui::muted(description));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let inner = egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(CornerRadius::same(10))
|
||||
.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(26.0);
|
||||
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(10.0);
|
||||
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(10.0);
|
||||
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(8))
|
||||
.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(6))
|
||||
.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(), 0.10);
|
||||
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(10))
|
||||
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||
.show(ui, |ui| {
|
||||
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||
})
|
||||
.response
|
||||
}
|
||||
@@ -67,8 +67,10 @@ pub struct VideoSurface {
|
||||
impl VideoSurface {
|
||||
/// Attach a renderer to `player` on the current OpenGL context.
|
||||
///
|
||||
/// `ctx` is woken whenever mpv has a new frame; without that the picture
|
||||
/// would only advance when the viewer moved the mouse.
|
||||
/// `wake` is called whenever mpv has a new frame, and should ask the window
|
||||
/// 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
|
||||
///
|
||||
@@ -78,11 +80,11 @@ impl VideoSurface {
|
||||
pub unsafe fn new(
|
||||
player: Arc<Player>,
|
||||
gl: Arc<glow::Context>,
|
||||
ctx: egui::Context,
|
||||
wake: impl Fn() + Send + Sync + 'static,
|
||||
) -> pstr_player::Result<Self> {
|
||||
// SAFETY: forwarded from this function's own contract.
|
||||
let mut renderer = unsafe { VideoRenderer::new(player)? };
|
||||
renderer.on_update(move || ctx.request_repaint());
|
||||
renderer.on_update(wake);
|
||||
|
||||
Ok(Self {
|
||||
gl,
|
||||
|
||||
@@ -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, ¤t) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
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)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Flavor {
|
||||
@@ -22,15 +23,18 @@ pub enum Flavor {
|
||||
Frappe,
|
||||
Macchiato,
|
||||
Mocha,
|
||||
/// Black, white and one loud red — the Persona 5 menus.
|
||||
Persona5,
|
||||
}
|
||||
|
||||
impl Flavor {
|
||||
pub const ALL: [Self; 5] = [
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::Proton,
|
||||
Self::Mocha,
|
||||
Self::Macchiato,
|
||||
Self::Frappe,
|
||||
Self::Latte,
|
||||
Self::Persona5,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -40,6 +44,7 @@ impl Flavor {
|
||||
Self::Frappe => "Catppuccin Frappé",
|
||||
Self::Macchiato => "Catppuccin Macchiato",
|
||||
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::Macchiato => "Dark, between Frappé and Mocha",
|
||||
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,
|
||||
Teal,
|
||||
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 {
|
||||
pub const ALL: [Self; 8] = [
|
||||
pub const ALL: [Self; 9] = [
|
||||
Self::Mauve,
|
||||
Self::Pink,
|
||||
Self::Sky,
|
||||
@@ -94,6 +105,7 @@ impl Accent {
|
||||
Self::Blue,
|
||||
Self::Teal,
|
||||
Self::Peach,
|
||||
Self::Red,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -106,6 +118,7 @@ impl Accent {
|
||||
Self::Blue => "Blue",
|
||||
Self::Teal => "Teal",
|
||||
Self::Peach => "Peach",
|
||||
Self::Red => "Red",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +436,36 @@ const MOCHA: Ramp = Ramp {
|
||||
light: false,
|
||||
};
|
||||
|
||||
/// Persona 5's menus: black, white, and a red that is not asking permission.
|
||||
///
|
||||
/// The three colours that matter are `crust`/`base` (black), `text` (white) and
|
||||
/// `red` — everything between them is that same red bled into the black, which
|
||||
/// is what keeps a card from reading as grey furniture in a palette that has no
|
||||
/// grey in it. The other hues exist because an accent can be any of them; they
|
||||
/// are pushed to full saturation to sit on a black page, which is the one thing
|
||||
/// this palette has in common with [`PROTON`].
|
||||
const PERSONA5: Ramp = Ramp {
|
||||
base: rgb(0x0a0708),
|
||||
mantle: rgb(0x140d0f),
|
||||
crust: rgb(0x000000),
|
||||
surface0: rgb(0x1d1215),
|
||||
surface1: rgb(0x33191d),
|
||||
subtext0: rgb(0xb59a9d),
|
||||
text: rgb(0xf5f2f2),
|
||||
pink: rgb(0xff2e63),
|
||||
mauve: rgb(0xb13bd6),
|
||||
sky: rgb(0x2ec7ff),
|
||||
sapphire: rgb(0x1f9bd0),
|
||||
blue: rgb(0x2f5ee0),
|
||||
lavender: rgb(0xa07bff),
|
||||
teal: rgb(0x12c2a0),
|
||||
green: rgb(0x2fbf4f),
|
||||
peach: rgb(0xff7a1a),
|
||||
yellow: rgb(0xffc400),
|
||||
red: rgb(0xe60012),
|
||||
light: false,
|
||||
};
|
||||
|
||||
impl Ramp {
|
||||
const fn of(flavor: Flavor) -> Self {
|
||||
match flavor {
|
||||
@@ -431,6 +474,7 @@ impl Ramp {
|
||||
Flavor::Frappe => FRAPPE,
|
||||
Flavor::Macchiato => MACCHIATO,
|
||||
Flavor::Mocha => MOCHA,
|
||||
Flavor::Persona5 => PERSONA5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +494,7 @@ impl Ramp {
|
||||
Accent::Blue => (self.blue, self.sapphire),
|
||||
Accent::Teal => (self.teal, self.green),
|
||||
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]
|
||||
fn turning_gradients_off_leaves_one_colour_to_draw() {
|
||||
let flat = Palette::resolve(Appearance {
|
||||
|
||||
+109
-23
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
||||
use crate::naming::{self, ParsedName};
|
||||
|
||||
/// Bump when adding a migration.
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
pub const SCHEMA_VERSION: i64 = 8;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
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)]
|
||||
pub struct OfflineFile {
|
||||
pub revision_id: String,
|
||||
@@ -157,6 +164,12 @@ pub struct TitleTrackPrefs {
|
||||
pub audio_language: Option<String>,
|
||||
pub subtitle_language: Option<String>,
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
@@ -164,10 +177,26 @@ impl Default for TitleTrackPrefs {
|
||||
audio_language: None,
|
||||
subtitle_language: None,
|
||||
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.
|
||||
const ABSOLUTE_SEASON: i64 = -1;
|
||||
|
||||
@@ -297,6 +326,12 @@ impl Catalog {
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
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(())
|
||||
}
|
||||
@@ -510,27 +545,43 @@ impl Catalog {
|
||||
}
|
||||
|
||||
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<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||
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| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
TitleTrackPrefs {
|
||||
audio_language: row.get(1)?,
|
||||
subtitle_language: row.get(2)?,
|
||||
subtitles: row.get::<_, i64>(3)? != 0,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
let rows =
|
||||
statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
@@ -559,7 +610,9 @@ impl Catalog {
|
||||
|
||||
/// Record where playback is.
|
||||
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)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||
@@ -567,15 +620,15 @@ impl Catalog {
|
||||
duration_secs = excluded.duration_secs,
|
||||
watched = excluded.watched,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1391,6 +1444,37 @@ mod tests {
|
||||
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
|
||||
/// silently mangle whatever the newer build recorded.
|
||||
#[test]
|
||||
@@ -1539,6 +1623,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,16 @@ impl AppDirs {
|
||||
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.
|
||||
pub fn shares_file(&self) -> PathBuf {
|
||||
self.config.join("shares.json")
|
||||
|
||||
@@ -331,6 +331,28 @@ impl Library {
|
||||
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> {
|
||||
self.titles.iter().find(|title| title.key == key)
|
||||
}
|
||||
@@ -590,6 +612,21 @@ mod tests {
|
||||
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]
|
||||
fn next_up_falls_through_to_the_first_unwatched() {
|
||||
let nodes = vec![
|
||||
|
||||
@@ -337,43 +337,91 @@ impl SharedLibrary {
|
||||
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
|
||||
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
|
||||
/// 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>> {
|
||||
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
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||
|
||||
let root = client.get_root_node().await?;
|
||||
let mut found = Vec::new();
|
||||
let mut folders = vec![root.uid.clone()];
|
||||
found.push(root);
|
||||
let mut level = vec![root.uid.clone()];
|
||||
let mut found = vec![root];
|
||||
|
||||
while let Some(folder) = folders.pop() {
|
||||
let child_uids: Vec<NodeUid> =
|
||||
client.enumerate_folder_children_node_uids(&folder).await?;
|
||||
if child_uids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Chunked and fanned out inside the SDK, and the node keys it
|
||||
// unlocks along the way are cached there, so a deep tree does not
|
||||
// re-derive the same ancestors per child.
|
||||
let children = client.enumerate_nodes(&child_uids).await?;
|
||||
for child in children {
|
||||
if child.is_folder() {
|
||||
folders.push(child.uid.clone());
|
||||
while !level.is_empty() {
|
||||
let listings = level.into_iter().map(|folder| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let child_uids: Vec<NodeUid> =
|
||||
client.enumerate_folder_children_node_uids(&folder).await?;
|
||||
if child_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Chunked and fanned out inside the SDK too, so this is a
|
||||
// multiplier on that concurrency rather than the only one.
|
||||
client.enumerate_nodes(&child_uids).await
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 `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
|
||||
/// 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.
|
||||
/// 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> {
|
||||
let (_, tail) = url
|
||||
.split_once("/urls/")
|
||||
|
||||
@@ -10,6 +10,8 @@ authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
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_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -19,4 +21,4 @@ tracing.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|
||||
@@ -14,11 +14,30 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::limiter::RateLimiter;
|
||||
use crate::matching::{Candidate, Query};
|
||||
use crate::provider::Provider;
|
||||
|
||||
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
|
||||
/// right title is essentially always in the first handful; asking for more costs
|
||||
/// them bandwidth and buys nothing.
|
||||
@@ -66,11 +85,16 @@ query ($id: Int) {
|
||||
|
||||
pub struct AniList {
|
||||
http: reqwest::Client,
|
||||
/// Shared by every lookup in flight — see [`crate::limiter`].
|
||||
limiter: RateLimiter,
|
||||
}
|
||||
|
||||
impl AniList {
|
||||
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 {
|
||||
/// One GraphQL request, with AniList's two ways of failing — an HTTP status
|
||||
/// and an `errors` array under a 200 — folded into one.
|
||||
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
||||
/// 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>(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
) -> Result<Option<T>> {
|
||||
let response = self.http.post(ENDPOINT).json(&body).send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// 429 is the one worth naming: AniList rate-limits by the minute,
|
||||
// and a caller that knows it was throttled can back off rather than
|
||||
// cache a miss for a title that would have matched.
|
||||
return Err(if status.as_u16() == 429 {
|
||||
Error::RateLimited
|
||||
} else {
|
||||
Error::Http(format!("AniList answered {status}"))
|
||||
});
|
||||
}
|
||||
let mut attempts = 0usize;
|
||||
let response = loop {
|
||||
self.limiter.acquire().await;
|
||||
let response = self.http.post(ENDPOINT).json(&body).send().await?;
|
||||
let status = response.status();
|
||||
|
||||
if status.as_u16() == 429 {
|
||||
// The pause holds every other lookup too, so the rest of the
|
||||
// run does not spend the window earning more 429s.
|
||||
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?;
|
||||
if let Some(errors) = payload.errors
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
pub mod anilist;
|
||||
pub mod error;
|
||||
pub mod limiter;
|
||||
pub mod matching;
|
||||
pub mod provider;
|
||||
pub mod service;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::limiter::RateLimiter;
|
||||
use crate::matching::{Candidate, Query};
|
||||
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
|
||||
/// artwork per title, which is bandwidth spent on pixels no tile can show.
|
||||
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
||||
|
||||
const POSTER_SIZE: &str = "w500";
|
||||
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 {
|
||||
http: reqwest::Client,
|
||||
api_key: String,
|
||||
language: String,
|
||||
limiter: RateLimiter,
|
||||
}
|
||||
|
||||
impl Tmdb {
|
||||
@@ -52,6 +63,7 @@ impl Tmdb {
|
||||
http,
|
||||
api_key,
|
||||
language,
|
||||
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,28 +84,12 @@ impl Provider for Tmdb {
|
||||
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
||||
}
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.get(ENDPOINT)
|
||||
.query(&[
|
||||
("api_key", self.api_key.as_str()),
|
||||
("query", query.name.as_str()),
|
||||
("language", self.language.as_str()),
|
||||
("include_adult", "false"),
|
||||
])
|
||||
.send()
|
||||
let payload: SearchResponse = self
|
||||
.get(
|
||||
ENDPOINT,
|
||||
&[("query", query.name.as_str()), ("include_adult", "false")],
|
||||
)
|
||||
.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
|
||||
.results
|
||||
.into_iter()
|
||||
@@ -172,7 +168,11 @@ impl Provider for 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>(
|
||||
&self,
|
||||
url: &str,
|
||||
@@ -181,23 +181,40 @@ impl Tmdb {
|
||||
if self.api_key.trim().is_empty() {
|
||||
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 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 mut attempts = 0usize;
|
||||
let response = loop {
|
||||
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]);
|
||||
}
|
||||
|
||||
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?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,4 @@ pub use player::{
|
||||
};
|
||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||
pub use render::VideoRenderer;
|
||||
pub use tracks::{Track, TrackKind, language_name};
|
||||
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
||||
@@ -94,6 +94,11 @@ pub enum PlayerEvent {
|
||||
/// Output volume, 0–100.
|
||||
Volume(f64),
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
self.mpv.observe_property("volume", Format::Double, 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(())
|
||||
}
|
||||
|
||||
@@ -449,6 +458,32 @@ impl Player {
|
||||
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<()> {
|
||||
self.mpv.command("stop", &[])?;
|
||||
Ok(())
|
||||
@@ -505,6 +540,8 @@ fn translate(event: Event<'_>) -> PlayerEvent {
|
||||
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
|
||||
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(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,
|
||||
|
||||
@@ -107,6 +107,56 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Track> {
|
||||
.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> {
|
||||
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
||||
let id: i64 = mpv
|
||||
@@ -242,6 +292,87 @@ mod tests {
|
||||
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]
|
||||
fn only_the_kinds_the_player_can_select_are_tracks() {
|
||||
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
|
||||
|
||||
@@ -70,7 +70,8 @@ pub struct DiskStats {
|
||||
|
||||
pub(crate) struct DiskCache {
|
||||
root: PathBuf,
|
||||
budget: u64,
|
||||
/// Atomic so it can be changed under a running cache, from settings.
|
||||
budget: std::sync::atomic::AtomicU64,
|
||||
index: Mutex<Index>,
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@ impl DiskCache {
|
||||
|
||||
let cache = Self {
|
||||
root: config.root,
|
||||
budget: config.budget_bytes,
|
||||
budget: std::sync::atomic::AtomicU64::new(config.budget_bytes),
|
||||
index: Mutex::new(Index {
|
||||
lru,
|
||||
bytes,
|
||||
@@ -169,7 +170,7 @@ impl DiskCache {
|
||||
/// written is a slower app, not a broken one.
|
||||
pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) {
|
||||
let len = block.len() as u64;
|
||||
if len > self.budget {
|
||||
if len > self.budget() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,6 +191,30 @@ impl DiskCache {
|
||||
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 {
|
||||
let index = self.lock();
|
||||
DiskStats {
|
||||
@@ -204,7 +229,7 @@ impl DiskCache {
|
||||
loop {
|
||||
let doomed = {
|
||||
let mut index = self.lock();
|
||||
if index.bytes <= self.budget {
|
||||
if index.bytes <= self.budget() {
|
||||
return;
|
||||
}
|
||||
match index.lru.pop_lru() {
|
||||
@@ -321,11 +346,9 @@ async fn write_entry(path: &Path, block: &[u8]) -> Result<()> {
|
||||
}
|
||||
tokio::fs::rename(&temp, path).await?;
|
||||
|
||||
let mut sidecar = tokio::fs::File::create(meta_path(path)).await?;
|
||||
sidecar
|
||||
.write_all(block.len().to_string().as_bytes())
|
||||
.await?;
|
||||
sidecar.sync_all().await?;
|
||||
// Not synced. The block's sync is the one the ordering depends on; a
|
||||
// sidecar lost to a crash only turns a valid entry into a miss.
|
||||
tokio::fs::write(meta_path(path), block.len().to_string()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -513,6 +536,31 @@ mod tests {
|
||||
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
|
||||
/// until enough new blocks arrive to trigger eviction.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -155,6 +155,24 @@ impl StreamSource {
|
||||
.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.
|
||||
///
|
||||
/// 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> {
|
||||
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 {
|
||||
|
||||
@@ -103,6 +103,11 @@ struct Inner {
|
||||
map: BlockMap,
|
||||
ring: Arc<BlockRing>,
|
||||
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.
|
||||
flight: Arc<SingleFlight<BlockKey, CachedBlock>>,
|
||||
readahead: usize,
|
||||
@@ -131,6 +136,28 @@ impl VideoStream {
|
||||
ring: Arc<BlockRing>,
|
||||
disk: Option<Arc<DiskCache>>,
|
||||
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 {
|
||||
let map = BlockMap::new(blocks.block_sizes());
|
||||
let readahead = clamp_readahead(readahead, ring.budget(), blocks.block_sizes());
|
||||
@@ -141,6 +168,7 @@ impl VideoStream {
|
||||
map,
|
||||
ring,
|
||||
disk,
|
||||
fill_disk,
|
||||
flight: Arc::new(SingleFlight::new()),
|
||||
readahead,
|
||||
readahead_slots: Arc::new(Semaphore::new(readahead.max(1))),
|
||||
@@ -176,6 +204,38 @@ impl VideoStream {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
.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();
|
||||
// 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));
|
||||
Ok(block)
|
||||
})
|
||||
@@ -891,4 +958,85 @@ mod tests {
|
||||
assert_eq!(stream.read_at(80, &mut buf).await.unwrap(), 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,4 +120,7 @@ an edge case.
|
||||
| Catalog | data dir, `catalog.db` | Yes — recrawl |
|
||||
| Watch state | same DB, own table | **No** — deliberately survives a recrawl |
|
||||
| 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 |
|
||||
+184
@@ -146,6 +146,190 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
||||
|
||||
## 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
|
||||
|
||||
**Symptom.** The appearance page on Android offers a *Paint the accent as a
|
||||
|
||||
@@ -101,6 +101,24 @@ for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.2
|
||||
for artwork arriving. Compose's defaults are close enough that nothing here
|
||||
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
|
||||
|
||||
Name it in the owning client first, restate it on the other side, and extend the
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
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
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
# builds from the registry alone.
|
||||
|
||||
pkgname=proton-stream
|
||||
pkgver=0.4.0
|
||||
pkgver=0.4.2
|
||||
pkgrel=1
|
||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user