mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-10 09:27:03 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e09129d0c | ||
|
|
476fec8c04 | ||
|
|
7af0969e38 | ||
|
|
3df0644bd8 | ||
|
|
5d079b05c9 | ||
|
|
4d44db0949 | ||
|
|
4a54af3245 | ||
|
|
7e734dbf19 | ||
|
|
e7eeaa518f | ||
|
|
39866bee8d | ||
|
|
85092fa893 | ||
|
|
2128a2d705 | ||
|
|
558446b9e4 |
No files matched your search
@@ -20,3 +20,9 @@
|
|||||||
/packaging/pkg/
|
/packaging/pkg/
|
||||||
/packaging/src/
|
/packaging/src/
|
||||||
/packaging/*.pkg.tar.zst
|
/packaging/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# iOS generated state: XcodeGen owns the project and the Info.plist, and
|
||||||
|
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
|
||||||
|
/ios/build/
|
||||||
|
/ios/*.xcodeproj/
|
||||||
|
/ios/ProtonStream/Info.plist
|
||||||
Generated
+104
-43
@@ -119,12 +119,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android-activity"
|
name = "android-activity"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -1767,6 +1761,15 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "egui-phosphor"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
|
||||||
|
dependencies = [
|
||||||
|
"egui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-wgpu"
|
name = "egui-wgpu"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@@ -2061,12 +2064,6 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2352,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"log",
|
"log",
|
||||||
"peniko",
|
"peniko",
|
||||||
@@ -2528,24 +2525,13 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash 0.1.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2554,7 +2540,7 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3243,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.18.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4313,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe"
|
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -4340,13 +4326,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.6.3"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047"
|
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4373,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4392,13 +4378,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
"dbus",
|
"dbus",
|
||||||
"directories",
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
"egui-phosphor",
|
||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4410,7 +4398,9 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"skrifa",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
|
"tauri-winrt-notification",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -4419,7 +4409,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4437,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4455,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4468,7 +4458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -4483,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5533,7 +5523,7 @@ dependencies = [
|
|||||||
"dispatch",
|
"dispatch",
|
||||||
"objc",
|
"objc",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"windows",
|
"windows 0.44.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5624,6 +5614,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"windows 0.62.2",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -6684,6 +6685,27 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -6697,6 +6719,17 @@ dependencies = [
|
|||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -6725,6 +6758,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6860,6 +6903,24 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-version"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
|
|||||||
+14
-4
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.3" }
|
proton-sdk = { version = "0.7.2" }
|
||||||
proton-drive-rs = { version = "0.6.3" }
|
proton-drive-rs = { version = "0.7.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -51,6 +51,8 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
# Local dates for the history page. Already in the tree through the SDK.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
|
|||||||
"x11",
|
"x11",
|
||||||
] }
|
] }
|
||||||
egui = "0.35"
|
egui = "0.35"
|
||||||
|
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
|
||||||
|
# font has them, at its own size and baseline; an icon font is one set.
|
||||||
|
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
|
||||||
|
# Tests only: reads the bundled fonts' character maps. The version epaint
|
||||||
|
# already builds, so it costs no extra compile.
|
||||||
|
skrifa = { version = "0.42", default-features = false, features = ["std"] }
|
||||||
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
||||||
# pulls in every exotic format is weight this app has no use for.
|
# pulls in every exotic format is weight this app has no use for.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
@@ -93,9 +101,11 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"]
|
|||||||
# hold it for.
|
# hold it for.
|
||||||
dbus = "0.9"
|
dbus = "0.9"
|
||||||
windows-sys = "0.61"
|
windows-sys = "0.61"
|
||||||
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
|
tauri-winrt-notification = "0.8"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.12"
|
lru = "0.18"
|
||||||
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
||||||
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
||||||
# be tested against a fake without a network or an account.
|
# be tested against a fake without a network or an account.
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
One window at a time: a second launch brings the open window forward and
|
||||||
|
hands it any share link it was given, then exits.
|
||||||
|
|
||||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||||
is broken:
|
is broken:
|
||||||
|
|
||||||
@@ -106,6 +110,9 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
- The block cache holds decrypted content. It lives in your cache directory,
|
- 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
|
can be deleted at any time the app is not running, and can be sized or
|
||||||
emptied from Settings → Storage.
|
emptied from Settings → Storage.
|
||||||
|
- A share link given as a command-line argument is visible to other users of
|
||||||
|
the machine in the process list while that launch runs, and may land in
|
||||||
|
shell history. Pasting it into the window (Ctrl+V) does neither.
|
||||||
- The desktop's media widget is handed the title, the episode and a `file://`
|
- 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
|
path to artwork already on disk — never a provider URL, so the shell never
|
||||||
contacts a metadata provider on its own.
|
contacts a metadata provider on its own.
|
||||||
@@ -115,3 +122,6 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
application and its combined native distribution are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||||
|
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes(
|
|||||||
/** The radius `pstr-app` draws everything at. */
|
/** The radius `pstr-app` draws everything at. */
|
||||||
internal const val CORNER = 8f
|
internal const val CORNER = 8f
|
||||||
|
|
||||||
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
|
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
|
||||||
internal const val CORNER_TIGHT = 4f
|
internal const val CORNER_TIGHT = 4f
|
||||||
|
|
||||||
/** A bottom sheet, which has no desktop counterpart. */
|
/** A bottom sheet, which has no desktop counterpart. */
|
||||||
|
|||||||
@@ -2,3 +2,9 @@
|
|||||||
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
||||||
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||||
disable_java_cleaner = true
|
disable_java_cleaner = true
|
||||||
|
|
||||||
|
[bindings.swift]
|
||||||
|
# The iOS client (`ios/`) compiles the generated file straight into the app
|
||||||
|
# target and reaches the C side through its bridging header, so the names are
|
||||||
|
# what that header and `ios/project.yml` expect.
|
||||||
|
module_name = "PstrBridge"
|
||||||
@@ -24,17 +24,25 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
|
egui-phosphor.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
souvlaki.workspace = true
|
souvlaki.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
skrifa.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus.workspace = true
|
dbus.workspace = true
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { workspace = true, features = ["Win32_System_Power"] }
|
# Named pipes, for handing a second launch's request to the open window.
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
|
||||||
|
tauri-winrt-notification.workspace = true
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
+135
-34
@@ -46,6 +46,7 @@ impl FrameTimer {
|
|||||||
started: std::time::Instant::now(),
|
started: std::time::Instant::now(),
|
||||||
page: match page {
|
page: match page {
|
||||||
Page::Library => "library",
|
Page::Library => "library",
|
||||||
|
Page::History => "history",
|
||||||
Page::Title(_) => "title",
|
Page::Title(_) => "title",
|
||||||
Page::Shares => "shares",
|
Page::Shares => "shares",
|
||||||
Page::Downloads => "downloads",
|
Page::Downloads => "downloads",
|
||||||
@@ -80,6 +81,8 @@ const UP_NEXT_SECONDS: f64 = 10.0;
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Page {
|
pub enum Page {
|
||||||
Library,
|
Library,
|
||||||
|
/// Everything played, newest first.
|
||||||
|
History,
|
||||||
/// One title, by [`pstr_core::library::Title::key`].
|
/// One title, by [`pstr_core::library::Title::key`].
|
||||||
Title(String),
|
Title(String),
|
||||||
Shares,
|
Shares,
|
||||||
@@ -91,6 +94,15 @@ pub enum Page {
|
|||||||
Player,
|
Player,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pages in the top bar, in order. Ctrl+1 to Ctrl+5 go by position here.
|
||||||
|
const NAV: [Page; 5] = [
|
||||||
|
Page::Library,
|
||||||
|
Page::History,
|
||||||
|
Page::Shares,
|
||||||
|
Page::Downloads,
|
||||||
|
Page::Settings,
|
||||||
|
];
|
||||||
|
|
||||||
/// Something a click asked for, applied once the frame is drawn.
|
/// Something a click asked for, applied once the frame is drawn.
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
Goto(Page),
|
Goto(Page),
|
||||||
@@ -177,6 +189,14 @@ pub enum Action {
|
|||||||
share_id: String,
|
share_id: String,
|
||||||
link_id: String,
|
link_id: String,
|
||||||
},
|
},
|
||||||
|
/// Take one file off the history page: forget its position and its
|
||||||
|
/// watched mark, with an Undo.
|
||||||
|
RemoveFromHistory {
|
||||||
|
share_id: String,
|
||||||
|
link_id: String,
|
||||||
|
},
|
||||||
|
/// Open or close the list of episodes beside the picture.
|
||||||
|
ToggleEpisodes,
|
||||||
/// Put watch states back as they were — what Undo does.
|
/// Put watch states back as they were — what Undo does.
|
||||||
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
|
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
|
||||||
ClearStreamCache,
|
ClearStreamCache,
|
||||||
@@ -293,6 +313,8 @@ pub struct LibraryView {
|
|||||||
built_with: (Filter, Sort),
|
built_with: (Filter, Sort),
|
||||||
/// Indices into [`Library::titles`].
|
/// Indices into [`Library::titles`].
|
||||||
pub matches: Vec<usize>,
|
pub matches: Vec<usize>,
|
||||||
|
/// Every played episode, newest first, for the history page.
|
||||||
|
pub history: Vec<ui::history::Entry>,
|
||||||
/// The "Continue watching" shelf, most recent first, as indices.
|
/// The "Continue watching" shelf, most recent first, as indices.
|
||||||
pub resumable: Vec<usize>,
|
pub resumable: Vec<usize>,
|
||||||
/// The library changed under the lists.
|
/// The library changed under the lists.
|
||||||
@@ -366,6 +388,7 @@ impl LibraryView {
|
|||||||
self.matches = matches.into_iter().filter_map(index_of).collect();
|
self.matches = matches.into_iter().filter_map(index_of).collect();
|
||||||
self.built_with = (self.filter, self.sort);
|
self.built_with = (self.filter, self.sort);
|
||||||
if self.stale {
|
if self.stale {
|
||||||
|
self.history = ui::history::entries(library);
|
||||||
self.resumable = library
|
self.resumable = library
|
||||||
.continue_watching()
|
.continue_watching()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -394,6 +417,8 @@ pub struct ShareForm {
|
|||||||
pub struct App {
|
pub struct App {
|
||||||
engine: Engine,
|
engine: Engine,
|
||||||
events: Receiver<Event>,
|
events: Receiver<Event>,
|
||||||
|
/// What later launches asked for. See [`crate::instance`].
|
||||||
|
requests: Receiver<crate::instance::Request>,
|
||||||
pub page: Page,
|
pub page: Page,
|
||||||
pub library: Library,
|
pub library: Library,
|
||||||
pub shares: Vec<Share>,
|
pub shares: Vec<Share>,
|
||||||
@@ -435,6 +460,8 @@ pub struct App {
|
|||||||
/// Chapters already skipped on their own in this player, so seeking back
|
/// Chapters already skipped on their own in this player, so seeking back
|
||||||
/// into an opening on purpose is not undone a frame later.
|
/// into an opening on purpose is not undone a frame later.
|
||||||
auto_skipped: (u64, Vec<i64>),
|
auto_skipped: (u64, Vec<i64>),
|
||||||
|
/// Whether the episode list is out on the player page.
|
||||||
|
pub episode_drawer: bool,
|
||||||
/// Set while the window is fullscreen, so `F` can toggle rather than only
|
/// Set while the window is fullscreen, so `F` can toggle rather than only
|
||||||
/// ever entering. egui has no way to ask the platform.
|
/// ever entering. egui has no way to ask the platform.
|
||||||
pub fullscreen: bool,
|
pub fullscreen: bool,
|
||||||
@@ -473,13 +500,17 @@ impl App {
|
|||||||
cc: &eframe::CreationContext<'_>,
|
cc: &eframe::CreationContext<'_>,
|
||||||
runtime: std::sync::Arc<tokio::runtime::Runtime>,
|
runtime: std::sync::Arc<tokio::runtime::Runtime>,
|
||||||
dirs: AppDirs,
|
dirs: AppDirs,
|
||||||
|
instance: crate::instance::Listener,
|
||||||
|
request: crate::instance::Request,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
|
let requests = instance.serve(&runtime, &cc.egui_ctx, request);
|
||||||
// After the engine, not before: the engine is what reads the stored
|
// After the engine, not before: the engine is what reads the stored
|
||||||
// theme, and a window that paints one frame in the default palette
|
// theme, and a window that paints one frame in the default palette
|
||||||
// before switching is a window that flashes on every launch.
|
// before switching is a window that flashes on every launch.
|
||||||
let window = crate::window::WindowMemory::new(dirs.window_file());
|
let window = crate::window::WindowMemory::new(dirs.window_file());
|
||||||
|
let notifier = crate::notify::Notifier::new(&dirs.cache);
|
||||||
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||||
theme::install_font_fallbacks(&cc.egui_ctx);
|
theme::install_fonts(&cc.egui_ctx);
|
||||||
theme::apply(&cc.egui_ctx, engine.appearance());
|
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||||
|
|
||||||
// Paint from the catalog immediately; the network catches up. A library
|
// Paint from the catalog immediately; the network catches up. A library
|
||||||
@@ -493,6 +524,7 @@ impl App {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
engine,
|
engine,
|
||||||
events,
|
events,
|
||||||
|
requests,
|
||||||
page: Page::Library,
|
page: Page::Library,
|
||||||
library: Library::default(),
|
library: Library::default(),
|
||||||
shares: Vec::new(),
|
shares: Vec::new(),
|
||||||
@@ -517,6 +549,7 @@ impl App {
|
|||||||
warmed: 0,
|
warmed: 0,
|
||||||
auto_skipped: (0, Vec::new()),
|
auto_skipped: (0, Vec::new()),
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
|
episode_drawer: false,
|
||||||
opening: None,
|
opening: None,
|
||||||
connecting: true,
|
connecting: true,
|
||||||
crawling: false,
|
crawling: false,
|
||||||
@@ -526,7 +559,7 @@ impl App {
|
|||||||
media_cover: None,
|
media_cover: None,
|
||||||
inhibitor: crate::inhibit::Inhibitor::new(),
|
inhibitor: crate::inhibit::Inhibitor::new(),
|
||||||
shown_page: (None, 0.0),
|
shown_page: (None, 0.0),
|
||||||
notifier: crate::notify::Notifier::new(),
|
notifier,
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
offline_files: std::collections::HashSet::new(),
|
offline_files: std::collections::HashSet::new(),
|
||||||
confirm_partial_delete: None,
|
confirm_partial_delete: None,
|
||||||
@@ -1053,6 +1086,39 @@ impl App {
|
|||||||
self.engine.set_playback_prefs(prefs, commit);
|
self.engine.set_playback_prefs(prefs, commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset one file to never played, with an Undo. What leaving Continue
|
||||||
|
/// watching and leaving the history both come down to: the two are read
|
||||||
|
/// off the same watch state.
|
||||||
|
fn forget_watch(
|
||||||
|
&mut self,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
share_id: String,
|
||||||
|
link_id: String,
|
||||||
|
place: &str,
|
||||||
|
) {
|
||||||
|
let found = self.library.titles.iter().find_map(|title| {
|
||||||
|
let episode = title.episodes().find(|episode| {
|
||||||
|
episode.node.share_id == share_id && episode.node.link_id == link_id
|
||||||
|
})?;
|
||||||
|
Some((title.name.clone(), episode.watch))
|
||||||
|
});
|
||||||
|
let Some((name, Some(before))) = found else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.engine.save_watch_state(
|
||||||
|
share_id.clone(),
|
||||||
|
link_id.clone(),
|
||||||
|
watch_state(0.0, before.duration_secs, false),
|
||||||
|
);
|
||||||
|
let now = ctx.input(|input| input.time);
|
||||||
|
self.toasts.push_with(
|
||||||
|
now,
|
||||||
|
format!("removed {name} from {place}"),
|
||||||
|
"Undo",
|
||||||
|
Action::RestoreWatch(vec![(share_id, link_id, before)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
||||||
match action {
|
match action {
|
||||||
Action::Goto(page) => self.page = page,
|
Action::Goto(page) => self.page = page,
|
||||||
@@ -1318,28 +1384,12 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Action::ForgetPosition { share_id, link_id } => {
|
Action::ForgetPosition { share_id, link_id } => {
|
||||||
let found = self.library.titles.iter().find_map(|title| {
|
self.forget_watch(ctx, share_id, link_id, "Continue watching");
|
||||||
let episode = title.episodes().find(|episode| {
|
|
||||||
episode.node.share_id == share_id && episode.node.link_id == link_id
|
|
||||||
})?;
|
|
||||||
Some((title.name.clone(), episode.watch))
|
|
||||||
});
|
|
||||||
let Some((name, Some(before))) = found else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.engine.save_watch_state(
|
|
||||||
share_id.clone(),
|
|
||||||
link_id.clone(),
|
|
||||||
watch_state(0.0, before.duration_secs, false),
|
|
||||||
);
|
|
||||||
let now = ctx.input(|input| input.time);
|
|
||||||
self.toasts.push_with(
|
|
||||||
now,
|
|
||||||
format!("removed {name} from Continue watching"),
|
|
||||||
"Undo",
|
|
||||||
Action::RestoreWatch(vec![(share_id, link_id, before)]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
Action::RemoveFromHistory { share_id, link_id } => {
|
||||||
|
self.forget_watch(ctx, share_id, link_id, "history");
|
||||||
|
}
|
||||||
|
Action::ToggleEpisodes => self.episode_drawer = !self.episode_drawer,
|
||||||
Action::RestoreWatch(states) => {
|
Action::RestoreWatch(states) => {
|
||||||
for (share_id, link_id, state) in states {
|
for (share_id, link_id, state) in states {
|
||||||
self.engine.save_watch_state(share_id, link_id, state);
|
self.engine.save_watch_state(share_id, link_id, state);
|
||||||
@@ -1432,14 +1482,14 @@ impl App {
|
|||||||
focus_search: bool,
|
focus_search: bool,
|
||||||
) {
|
) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Heading
|
theme::Role::Heading
|
||||||
.rich("proton-stream")
|
.rich("proton-stream")
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::accent()),
|
.color(theme::accent()),
|
||||||
);
|
);
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
let on_library = matches!(self.page, Page::Library | Page::Title(_));
|
let on_library = matches!(self.page, Page::Library | Page::Title(_));
|
||||||
let active = self
|
let active = self
|
||||||
@@ -1459,12 +1509,13 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
format!("Downloads ({active})")
|
format!("Downloads ({active})")
|
||||||
};
|
};
|
||||||
let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings];
|
let pages = NAV;
|
||||||
let clicked = ui::tabs(
|
let clicked = ui::tabs(
|
||||||
ui,
|
ui,
|
||||||
ui.id().with("nav"),
|
ui.id().with("nav"),
|
||||||
&[
|
&[
|
||||||
("Library", on_library),
|
("Library", on_library),
|
||||||
|
("History", self.page == Page::History),
|
||||||
("Shares", self.page == Page::Shares),
|
("Shares", self.page == Page::Shares),
|
||||||
(&downloads, self.page == Page::Downloads),
|
(&downloads, self.page == Page::Downloads),
|
||||||
("Settings", self.page == Page::Settings),
|
("Settings", self.page == Page::Settings),
|
||||||
@@ -1475,7 +1526,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
if self.crawling {
|
if self.crawling {
|
||||||
if ui
|
if ui
|
||||||
.small_button("Stop")
|
.small_button("Stop")
|
||||||
@@ -1493,7 +1544,10 @@ impl App {
|
|||||||
ui.add(egui::Spinner::new().size(16.0));
|
ui.add(egui::Spinner::new().size(16.0));
|
||||||
ui.label(ui::muted("connecting"));
|
ui.label(ui::muted("connecting"));
|
||||||
} else if ui
|
} else if ui
|
||||||
.button("Refresh")
|
.button(format!(
|
||||||
|
"{} Refresh",
|
||||||
|
egui_phosphor::regular::ARROW_CLOCKWISE
|
||||||
|
))
|
||||||
.on_hover_text("Re-crawl every share")
|
.on_hover_text("Re-crawl every share")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -1501,7 +1555,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if on_library && !self.library.is_empty() {
|
if on_library && !self.library.is_empty() {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui::search_field(ui, search, focus_search);
|
ui::search_field(ui, search, focus_search);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1524,6 +1578,15 @@ impl eframe::App for App {
|
|||||||
self.window.observe(ctx);
|
self.window.observe(ctx);
|
||||||
|
|
||||||
let mut actions: Vec<Action> = Vec::new();
|
let mut actions: Vec<Action> = Vec::new();
|
||||||
|
while let Ok(request) = self.requests.try_recv() {
|
||||||
|
// A second launch is someone looking for this window, so it comes
|
||||||
|
// forward even when there is nothing else to do.
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||||
|
if let crate::instance::Request::AddShare(url) = request {
|
||||||
|
actions.push(Action::PasteShare(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
self.media.drain(self.playback.as_ref(), &mut actions);
|
self.media.drain(self.playback.as_ref(), &mut actions);
|
||||||
let cover = self.media_cover();
|
let cover = self.media_cover();
|
||||||
self.media.show(self.playback.as_ref(), cover.as_deref());
|
self.media.show(self.playback.as_ref(), cover.as_deref());
|
||||||
@@ -1552,7 +1615,13 @@ impl eframe::App for App {
|
|||||||
speed: playback.speed,
|
speed: playback.speed,
|
||||||
skip_to: playback.skippable().map(|(_, end)| end),
|
skip_to: playback.skippable().map(|(_, end)| end),
|
||||||
});
|
});
|
||||||
shortcuts(ctx, self.fullscreen, state, &mut actions);
|
shortcuts(
|
||||||
|
ctx,
|
||||||
|
self.fullscreen,
|
||||||
|
self.episode_drawer,
|
||||||
|
state,
|
||||||
|
&mut actions,
|
||||||
|
);
|
||||||
|
|
||||||
// Before the draw, because drawing does not mutate — and it can
|
// Before the draw, because drawing does not mutate — and it can
|
||||||
// push an action of its own, which is why it takes the same list.
|
// push an action of its own, which is why it takes the same list.
|
||||||
@@ -1562,8 +1631,30 @@ impl eframe::App for App {
|
|||||||
playback,
|
playback,
|
||||||
opening,
|
opening,
|
||||||
overlay,
|
overlay,
|
||||||
|
engine,
|
||||||
|
library,
|
||||||
|
thumbs,
|
||||||
|
posters,
|
||||||
|
metadata,
|
||||||
|
episodes,
|
||||||
|
episode_drawer,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
|
let list = playback
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|playback| library.get(&playback.target.title_key))
|
||||||
|
.filter(|title| title.episode_count() > 1)
|
||||||
|
.map(|title| ui::player::Episodes {
|
||||||
|
title,
|
||||||
|
art: ui::Art {
|
||||||
|
engine,
|
||||||
|
thumbs,
|
||||||
|
posters,
|
||||||
|
metadata,
|
||||||
|
episodes,
|
||||||
|
},
|
||||||
|
open: *episode_drawer,
|
||||||
|
});
|
||||||
// No panels: the controls are drawn over the picture, and a nav bar
|
// No panels: the controls are drawn over the picture, and a nav bar
|
||||||
// above a film is the one thing every player agrees not to do.
|
// above a film is the one thing every player agrees not to do.
|
||||||
egui::CentralPanel::default()
|
egui::CentralPanel::default()
|
||||||
@@ -1578,6 +1669,7 @@ impl eframe::App for App {
|
|||||||
overlay,
|
overlay,
|
||||||
up_next,
|
up_next,
|
||||||
neighbours,
|
neighbours,
|
||||||
|
episodes: list,
|
||||||
},
|
},
|
||||||
&mut actions,
|
&mut actions,
|
||||||
);
|
);
|
||||||
@@ -1773,6 +1865,9 @@ impl eframe::App for App {
|
|||||||
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
||||||
}
|
}
|
||||||
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
|
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
|
||||||
|
Page::History => {
|
||||||
|
ui::history::show(ui, &mut art, library, &view.history, &mut actions)
|
||||||
|
}
|
||||||
// Drawn above, without any of these panels.
|
// Drawn above, without any of these panels.
|
||||||
Page::Player => {}
|
Page::Player => {}
|
||||||
}
|
}
|
||||||
@@ -1911,7 +2006,7 @@ fn screenshot_name(title: &str, caption: &str, position: f64) -> String {
|
|||||||
/// rather than in one: the cut between two pages of tiles reads as a flicker,
|
/// rather than in one: the cut between two pages of tiles reads as a flicker,
|
||||||
/// a short fade as a change of place.
|
/// a short fade as a change of place.
|
||||||
fn page_fade(ctx: &egui::Context, shown: &mut (Option<Page>, f64), page: &Page) -> f32 {
|
fn page_fade(ctx: &egui::Context, shown: &mut (Option<Page>, f64), page: &Page) -> f32 {
|
||||||
const SECONDS: f64 = 0.14;
|
const SECONDS: f64 = theme::motion::STATE as f64;
|
||||||
let now = ctx.input(|input| input.time);
|
let now = ctx.input(|input| input.time);
|
||||||
if shown.0.as_ref() != Some(page) {
|
if shown.0.as_ref() != Some(page) {
|
||||||
// The first page of a launch is there at once; only changes fade.
|
// The first page of a launch is there at once; only changes fade.
|
||||||
@@ -1922,7 +2017,7 @@ fn page_fade(ctx: &egui::Context, shown: &mut (Option<Page>, f64), page: &Page)
|
|||||||
if progress < 1.0 {
|
if progress < 1.0 {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
1.0 - (1.0 - progress).powi(3)
|
theme::motion::ease_out(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the top bar says while shares are being listed: one share by name,
|
/// What the top bar says while shares are being listed: one share by name,
|
||||||
@@ -2005,13 +2100,14 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
|
|||||||
(Key::Num2, 1),
|
(Key::Num2, 1),
|
||||||
(Key::Num3, 2),
|
(Key::Num3, 2),
|
||||||
(Key::Num4, 3),
|
(Key::Num4, 3),
|
||||||
|
(Key::Num5, 4),
|
||||||
] {
|
] {
|
||||||
if input.consume_key(Modifiers::COMMAND, key) {
|
if input.consume_key(Modifiers::COMMAND, key) {
|
||||||
tab = Some(index);
|
tab = Some(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if input.consume_key(Modifiers::COMMAND, Key::Comma) {
|
if input.consume_key(Modifiers::COMMAND, Key::Comma) {
|
||||||
tab = Some(3);
|
tab = Some(4);
|
||||||
}
|
}
|
||||||
// A share link pasted anywhere outside a text field is someone adding
|
// A share link pasted anywhere outside a text field is someone adding
|
||||||
// a share, wherever they happen to be.
|
// a share, wherever they happen to be.
|
||||||
@@ -2026,7 +2122,7 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(index) = tab {
|
if let Some(index) = tab {
|
||||||
let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone();
|
let page = NAV[index].clone();
|
||||||
actions.push(Action::Goto(page));
|
actions.push(Action::Goto(page));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2069,6 +2165,7 @@ fn step_speed(speed: f64, faster: bool) -> f64 {
|
|||||||
fn shortcuts(
|
fn shortcuts(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
fullscreen: bool,
|
fullscreen: bool,
|
||||||
|
drawer: bool,
|
||||||
playing: Option<PlayerKeys>,
|
playing: Option<PlayerKeys>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -2098,6 +2195,7 @@ fn shortcuts(
|
|||||||
Key::S,
|
Key::S,
|
||||||
Key::OpenBracket,
|
Key::OpenBracket,
|
||||||
Key::CloseBracket,
|
Key::CloseBracket,
|
||||||
|
Key::E,
|
||||||
Key::Escape,
|
Key::Escape,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2141,6 +2239,9 @@ fn shortcuts(
|
|||||||
// Escape leaves fullscreen first and the page second, which is what
|
// Escape leaves fullscreen first and the page second, which is what
|
||||||
// it does everywhere else and what stops one press from both
|
// it does everywhere else and what stops one press from both
|
||||||
// un-maximising the window and hiding the film.
|
// un-maximising the window and hiding the film.
|
||||||
|
Key::E => actions.push(Action::ToggleEpisodes),
|
||||||
|
// The episode list is the innermost thing open, so it goes first.
|
||||||
|
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
|
||||||
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
|
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
|
||||||
Key::Escape => actions.push(Action::LeavePlayer),
|
Key::Escape => actions.push(Action::LeavePlayer),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -307,6 +307,10 @@ pub struct Engine {
|
|||||||
/// whenever the settings change, so nothing holds a client for a provider
|
/// whenever the settings change, so nothing holds a client for a provider
|
||||||
/// the viewer has switched away from.
|
/// the viewer has switched away from.
|
||||||
metadata: Arc<Mutex<Enrichment>>,
|
metadata: Arc<Mutex<Enrichment>>,
|
||||||
|
/// False until [`Engine::metadata`] holds what the settings resolve to.
|
||||||
|
/// Only ever false at launch, while the API key is read off the UI
|
||||||
|
/// thread; see [`Engine::new`].
|
||||||
|
enrichment_settled: Arc<tokio::sync::watch::Sender<bool>>,
|
||||||
/// Volume, mute and language preferences. Held here rather than in the UI
|
/// Volume, mute and language preferences. Held here rather than in the UI
|
||||||
/// because a new player is built from them, and the player is started from
|
/// because a new player is built from them, and the player is started from
|
||||||
/// a background event.
|
/// a background event.
|
||||||
@@ -402,6 +406,21 @@ impl Engine {
|
|||||||
Appearance::default()
|
Appearance::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A provider with an API key needs it out of the OS credential store,
|
||||||
|
// and that can be a D-Bus round trip to a keyring that has to unlock
|
||||||
|
// first — not something to hold the first frame for. Built on the
|
||||||
|
// blocking pool instead; until it lands, everything that needs the
|
||||||
|
// provider waits for it rather than concluding there is none.
|
||||||
|
let deferred = config.enabled && config.provider.needs_api_key();
|
||||||
|
let enrichment = if deferred {
|
||||||
|
Enrichment {
|
||||||
|
config: config.clone(),
|
||||||
|
service: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Enrichment::build(config.clone())
|
||||||
|
};
|
||||||
|
|
||||||
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
|
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
|
||||||
tracing::warn!("read show track preferences: {error}");
|
tracing::warn!("read show track preferences: {error}");
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
@@ -415,7 +434,8 @@ impl Engine {
|
|||||||
catalog: Arc::new(Mutex::new(catalog)),
|
catalog: Arc::new(Mutex::new(catalog)),
|
||||||
thumbnails: Arc::new(tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY)),
|
thumbnails: Arc::new(tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY)),
|
||||||
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
|
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
|
||||||
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
|
metadata: Arc::new(Mutex::new(enrichment)),
|
||||||
|
enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0),
|
||||||
prefs: Arc::new(Mutex::new(prefs)),
|
prefs: Arc::new(Mutex::new(prefs)),
|
||||||
appearance: Arc::new(Mutex::new(appearance)),
|
appearance: Arc::new(Mutex::new(appearance)),
|
||||||
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
||||||
@@ -428,6 +448,12 @@ impl Engine {
|
|||||||
events,
|
events,
|
||||||
ctx,
|
ctx,
|
||||||
};
|
};
|
||||||
|
if deferred {
|
||||||
|
let deferred = engine.clone();
|
||||||
|
engine.runtime.spawn_blocking(move || {
|
||||||
|
deferred.install_enrichment(Enrichment::build(config), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok((engine, receiver))
|
Ok((engine, receiver))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,12 +563,15 @@ impl Engine {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- shares
|
// ---------------------------------------------------------------- shares
|
||||||
|
|
||||||
/// Re-read the share list.
|
/// Re-read the share list, off the UI thread: it is a file read, and at
|
||||||
|
/// launch the first frame should not wait on the disk for it.
|
||||||
pub fn load_shares(&self) {
|
pub fn load_shares(&self) {
|
||||||
match self.store.list() {
|
let engine = self.clone();
|
||||||
Ok(shares) => self.emit(Event::Shares(shares)),
|
self.runtime
|
||||||
Err(error) => self.fail("read the share list", error),
|
.spawn_blocking(move || match engine.store.list() {
|
||||||
}
|
Ok(shares) => engine.emit(Event::Shares(shares)),
|
||||||
|
Err(error) => engine.fail("read the share list", error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a share, then open and crawl it — which is what "add" means to
|
/// Add a share, then open and crawl it — which is what "add" means to
|
||||||
@@ -1742,6 +1771,38 @@ impl Engine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put `built` in place and mark the provider settled.
|
||||||
|
///
|
||||||
|
/// `only_if_unsettled` is for the launch-time build: a settings change
|
||||||
|
/// that landed while it was reading the keyring is newer, and wins. The
|
||||||
|
/// check and the write share the lock, so the two cannot interleave.
|
||||||
|
fn install_enrichment(&self, built: Enrichment, only_if_unsettled: bool) {
|
||||||
|
let mut slot = self.metadata.lock();
|
||||||
|
if only_if_unsettled && *self.enrichment_settled.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*slot = built;
|
||||||
|
self.enrichment_settled.send_replace(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provider, once the launch-time build has settled. `None` when
|
||||||
|
/// enrichment is off or has no usable key.
|
||||||
|
async fn metadata_service(&self) -> Option<MetadataService> {
|
||||||
|
let mut settled = self.enrichment_settled.subscribe();
|
||||||
|
let _ = settled.wait_for(|settled| *settled).await;
|
||||||
|
self.metadata.lock().service.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why there is no provider, for the viewer who asked for one.
|
||||||
|
fn no_service_reason(&self) -> String {
|
||||||
|
let config = self.metadata_config();
|
||||||
|
if !config.enabled {
|
||||||
|
"turn on metadata enrichment first".into()
|
||||||
|
} else {
|
||||||
|
format!("{} needs an API key", config.provider.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The enrichment settings as they stand.
|
/// The enrichment settings as they stand.
|
||||||
pub fn metadata_config(&self) -> MetadataConfig {
|
pub fn metadata_config(&self) -> MetadataConfig {
|
||||||
self.metadata.lock().config.clone()
|
self.metadata.lock().config.clone()
|
||||||
@@ -1774,7 +1835,7 @@ impl Engine {
|
|||||||
tracing::warn!("clear stored metadata: {error}");
|
tracing::warn!("clear stored metadata: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
*engine.metadata.lock() = Enrichment::build(config.clone());
|
engine.install_enrichment(Enrichment::build(config.clone()), false);
|
||||||
engine.emit(Event::MetadataConfig(config));
|
engine.emit(Event::MetadataConfig(config));
|
||||||
engine.load_metadata();
|
engine.load_metadata();
|
||||||
});
|
});
|
||||||
@@ -1788,7 +1849,7 @@ impl Engine {
|
|||||||
return engine.fail("store the API key", error);
|
return engine.fail("store the API key", error);
|
||||||
}
|
}
|
||||||
let config = engine.metadata_config();
|
let config = engine.metadata_config();
|
||||||
*engine.metadata.lock() = Enrichment::build(config);
|
engine.install_enrichment(Enrichment::build(config), false);
|
||||||
engine.emit(Event::Status(if key.trim().is_empty() {
|
engine.emit(Event::Status(if key.trim().is_empty() {
|
||||||
format!("cleared the {} key", provider.label())
|
format!("cleared the {} key", provider.label())
|
||||||
} else {
|
} else {
|
||||||
@@ -1837,13 +1898,8 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_match(&self, titles: Vec<Title>, force: bool) {
|
async fn run_match(&self, titles: Vec<Title>, force: bool) {
|
||||||
let Some(service) = self.metadata.lock().service.clone() else {
|
let Some(service) = self.metadata_service().await else {
|
||||||
let config = self.metadata_config();
|
return self.emit(Event::Error(self.no_service_reason()));
|
||||||
return self.emit(Event::Error(if !config.enabled {
|
|
||||||
"turn on metadata enrichment first".into()
|
|
||||||
} else {
|
|
||||||
format!("{} needs an API key", config.provider.label())
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
let provider = service.provider();
|
let provider = service.provider();
|
||||||
|
|
||||||
@@ -1979,21 +2035,13 @@ impl Engine {
|
|||||||
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
|
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
|
||||||
/// stored: this is a look, and only [`Engine::choose_match`] writes.
|
/// stored: this is a look, and only [`Engine::choose_match`] writes.
|
||||||
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
|
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
|
||||||
let Some(service) = self.metadata.lock().service.clone() else {
|
|
||||||
let config = self.metadata_config();
|
|
||||||
return self.emit(Event::MatchSearchFailed {
|
|
||||||
title_key,
|
|
||||||
error: if !config.enabled {
|
|
||||||
"turn on metadata enrichment first".into()
|
|
||||||
} else {
|
|
||||||
format!("{} needs an API key", config.provider.label())
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
let permits = Arc::clone(&self.lookups);
|
let permits = Arc::clone(&self.lookups);
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
|
let Some(service) = engine.metadata_service().await else {
|
||||||
|
let error = engine.no_service_reason();
|
||||||
|
return engine.emit(Event::MatchSearchFailed { title_key, error });
|
||||||
|
};
|
||||||
// The same permit the batch run takes: a viewer typing in the search
|
// The same permit the batch run takes: a viewer typing in the search
|
||||||
// box while a match run is going must not be what earns the 429.
|
// box while a match run is going must not be what earns the 429.
|
||||||
let Ok(_permit) = permits.acquire().await else {
|
let Ok(_permit) = permits.acquire().await else {
|
||||||
@@ -2019,12 +2067,11 @@ impl Engine {
|
|||||||
/// to the next match run, because the point of choosing an entry by hand is
|
/// to the next match run, because the point of choosing an entry by hand is
|
||||||
/// usually that its episode names were wrong too.
|
/// usually that its episode names were wrong too.
|
||||||
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
|
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
|
||||||
let Some(service) = self.metadata.lock().service.clone() else {
|
|
||||||
return self.emit(Event::Error("turn on metadata enrichment first".into()));
|
|
||||||
};
|
|
||||||
|
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
|
let Some(service) = engine.metadata_service().await else {
|
||||||
|
return engine.emit(Event::Error(engine.no_service_reason()));
|
||||||
|
};
|
||||||
let name = found.name.clone();
|
let name = found.name.clone();
|
||||||
let record = service.chosen(title.key.clone(), found.clone());
|
let record = service.chosen(title.key.clone(), found.clone());
|
||||||
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
||||||
@@ -2103,7 +2150,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(service) = engine.metadata.lock().service.clone() else {
|
let Some(service) = engine.metadata_service().await else {
|
||||||
// Enrichment was turned off between the request and now.
|
// Enrichment was turned off between the request and now.
|
||||||
return engine.emit(Event::PosterMissing { key: title_key });
|
return engine.emit(Event::PosterMissing { key: title_key });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
//! One window at a time.
|
||||||
|
//!
|
||||||
|
//! A second launch hands what it was started with — a share link, or nothing
|
||||||
|
//! — to the window already open, and exits. Without this, a second click on
|
||||||
|
//! the launcher opened a second window on the same catalog: two engines
|
||||||
|
//! writing the same watch state, two crawls of the same shares, and two films
|
||||||
|
//! playing over each other.
|
||||||
|
//!
|
||||||
|
//! The channel between the two is a Unix socket in the user's runtime
|
||||||
|
//! directory, or a named pipe on Windows. Anything that goes wrong setting it
|
||||||
|
//! up is logged and the app runs on without it: a second window is a nuisance,
|
||||||
|
//! and a launch that refuses to start because of this is a bug.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
|
||||||
|
use pstr_core::config::AppDirs;
|
||||||
|
|
||||||
|
/// What a later launch asks of the window already open.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Request {
|
||||||
|
/// Come to the front.
|
||||||
|
Raise,
|
||||||
|
/// Come to the front, with this share link in the Add form.
|
||||||
|
AddShare(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
/// What a launch was started with: the first argument that is a share
|
||||||
|
/// link, so `proton-stream 'https://drive.proton.me/urls/…#…'` adds it.
|
||||||
|
pub fn from_args(args: impl IntoIterator<Item = String>) -> Self {
|
||||||
|
args.into_iter()
|
||||||
|
.map(|arg| arg.trim().to_owned())
|
||||||
|
.find(|arg| pstr_core::shares::share_token(arg).is_ok())
|
||||||
|
.map_or(Self::Raise, Self::AddShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line on the wire.
|
||||||
|
fn encode(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Raise => "raise\n".to_owned(),
|
||||||
|
Self::AddShare(url) => format!("add {url}\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other end of [`Request::encode`]. Anything else is ignored rather
|
||||||
|
/// than guessed at — and a link that is not a share link is not one to
|
||||||
|
/// put in the form.
|
||||||
|
fn decode(line: &str) -> Option<Self> {
|
||||||
|
let line = line.trim();
|
||||||
|
if line == "raise" {
|
||||||
|
return Some(Self::Raise);
|
||||||
|
}
|
||||||
|
let url = line.strip_prefix("add ")?.trim();
|
||||||
|
pstr_core::shares::share_token(url)
|
||||||
|
.is_ok()
|
||||||
|
.then(|| Self::AddShare(url.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a launch spends handing its request over before giving up on it.
|
||||||
|
const HAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
/// The most a request may be. A share link is a few hundred bytes.
|
||||||
|
const MOST: u64 = 8 * 1024;
|
||||||
|
|
||||||
|
/// The outcome of [`claim`].
|
||||||
|
pub enum Claim {
|
||||||
|
/// This is the window. The listener answers later launches.
|
||||||
|
First(Listener),
|
||||||
|
/// A window was already open and has the request. This launch is done.
|
||||||
|
Handed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Become the one window, or hand `request` to the one there is.
|
||||||
|
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
|
||||||
|
platform::claim(runtime, dirs, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests from later launches, until the process ends.
|
||||||
|
pub struct Listener {
|
||||||
|
inner: Option<platform::Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Listener {
|
||||||
|
/// Start answering. `first` is this launch's own request, which arrives
|
||||||
|
/// through the same channel as the rest so there is one place to act on it.
|
||||||
|
pub fn serve(
|
||||||
|
self,
|
||||||
|
runtime: &tokio::runtime::Runtime,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
first: Request,
|
||||||
|
) -> Receiver<Request> {
|
||||||
|
let (sender, receiver) = channel();
|
||||||
|
if first != Request::Raise {
|
||||||
|
let _ = sender.send(first);
|
||||||
|
}
|
||||||
|
if let Some(inner) = self.inner {
|
||||||
|
platform::serve(
|
||||||
|
inner,
|
||||||
|
runtime,
|
||||||
|
Deliver {
|
||||||
|
sender,
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a received request goes: the app's queue, and a repaint so it is
|
||||||
|
/// acted on now rather than when the pointer next moves.
|
||||||
|
struct Deliver {
|
||||||
|
sender: Sender<Request>,
|
||||||
|
ctx: egui::Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deliver {
|
||||||
|
fn line(&self, text: &str) {
|
||||||
|
let Some(request) = Request::decode(text) else {
|
||||||
|
tracing::debug!("instance: ignored a request that did not parse");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = self.sender.send(request);
|
||||||
|
self.ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod platform {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
|
||||||
|
|
||||||
|
pub struct Inner(UnixListener);
|
||||||
|
|
||||||
|
/// In the runtime directory where there is one: it is the user's alone and
|
||||||
|
/// emptied at logout, which is exactly the life a socket should have.
|
||||||
|
fn socket(dirs: &AppDirs) -> PathBuf {
|
||||||
|
directories::BaseDirs::new()
|
||||||
|
.and_then(|base| base.runtime_dir().map(std::path::Path::to_path_buf))
|
||||||
|
.unwrap_or_else(|| dirs.cache.clone())
|
||||||
|
.join("io.narl.proton-stream.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn claim(_: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
|
||||||
|
let path = socket(dirs);
|
||||||
|
if let Ok(mut stream) = UnixStream::connect(&path) {
|
||||||
|
let _ = stream.set_write_timeout(Some(HAND_TIMEOUT));
|
||||||
|
match stream.write_all(request.encode().as_bytes()) {
|
||||||
|
Ok(()) => return Claim::Handed,
|
||||||
|
Err(error) => tracing::warn!("instance: hand over to the open window: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nobody answered, so a socket file still there is left over from a
|
||||||
|
// window that did not exit cleanly.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let listener = match UnixListener::bind(&path) {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("instance: listen at {}: {error}", path.display());
|
||||||
|
return Claim::First(Listener { inner: None });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The runtime directory is private already; the cache fallback is
|
||||||
|
// not necessarily, and a share link is a secret.
|
||||||
|
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||||
|
Claim::First(Listener {
|
||||||
|
inner: Some(Inner(listener)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(inner: Inner, _: &tokio::runtime::Runtime, deliver: Deliver) {
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("instance".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for stream in inner.0.incoming() {
|
||||||
|
let Ok(stream) = stream else { continue };
|
||||||
|
let _ = stream.set_read_timeout(Some(HAND_TIMEOUT));
|
||||||
|
let mut text = String::new();
|
||||||
|
if stream.take(MOST).read_to_string(&mut text).is_ok() {
|
||||||
|
deliver.line(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
tracing::warn!("instance thread: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
|
||||||
|
|
||||||
|
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
|
||||||
|
|
||||||
|
pub struct Inner {
|
||||||
|
name: String,
|
||||||
|
first: NamedPipeServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pipe's name. Pipe names are machine-wide, so a fixed one could be
|
||||||
|
/// taken first by another account on the same machine, which would then
|
||||||
|
/// be handed this user's share links. The name carries a random part kept
|
||||||
|
/// in this user's own config directory, which nobody else can read.
|
||||||
|
fn pipe(dirs: &AppDirs) -> String {
|
||||||
|
let path = dirs.config.join("instance.key");
|
||||||
|
let key = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|key| key.trim().to_owned())
|
||||||
|
.filter(|key| key.len() == 32 && key.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// `RandomState` is seeded from the OS's random source, once
|
||||||
|
// per process and stepped per instance after that.
|
||||||
|
let mut key = String::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let word = std::collections::hash_map::RandomState::new()
|
||||||
|
.build_hasher()
|
||||||
|
.finish();
|
||||||
|
key.push_str(&format!("{word:016x}"));
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::write(&path, &key) {
|
||||||
|
tracing::warn!("instance: store the pipe key: {error}");
|
||||||
|
}
|
||||||
|
key
|
||||||
|
});
|
||||||
|
format!(r"\\.\pipe\io.narl.proton-stream.{key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server(name: &str, first: bool) -> std::io::Result<NamedPipeServer> {
|
||||||
|
ServerOptions::new()
|
||||||
|
.first_pipe_instance(first)
|
||||||
|
.reject_remote_clients(true)
|
||||||
|
.access_outbound(false)
|
||||||
|
.create(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
|
||||||
|
let name = pipe(dirs);
|
||||||
|
// Registering a pipe with tokio needs its reactor.
|
||||||
|
let _entered = runtime.enter();
|
||||||
|
match server(&name, true) {
|
||||||
|
Ok(first) => Claim::First(Listener {
|
||||||
|
inner: Some(Inner { name, first }),
|
||||||
|
}),
|
||||||
|
// Taken: there is a window. Hand it the request.
|
||||||
|
Err(_) => {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
match std::fs::OpenOptions::new().write(true).open(&name) {
|
||||||
|
Ok(mut pipe) => {
|
||||||
|
if let Err(error) = pipe.write_all(request.encode().as_bytes()) {
|
||||||
|
tracing::warn!("instance: hand over to the open window: {error}");
|
||||||
|
}
|
||||||
|
return Claim::Handed;
|
||||||
|
}
|
||||||
|
// Every instance busy with another launch: wait a turn.
|
||||||
|
Err(_) if started.elapsed() < HAND_TIMEOUT => {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("instance: reach the open window: {error}");
|
||||||
|
return Claim::First(Listener { inner: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(inner: Inner, runtime: &tokio::runtime::Runtime, deliver: Deliver) {
|
||||||
|
runtime.spawn(async move {
|
||||||
|
let Inner { name, mut first } = inner;
|
||||||
|
loop {
|
||||||
|
if first.connect().await.is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let connected = first;
|
||||||
|
// The next instance is up before this one is read, so a launch
|
||||||
|
// right behind this one finds a pipe to connect to.
|
||||||
|
first = match server(&name, false) {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("instance: reopen the pipe: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut limited = connected.take(MOST);
|
||||||
|
let read = limited.read_to_string(&mut text);
|
||||||
|
if let Ok(Ok(_)) = tokio::time::timeout(HAND_TIMEOUT, read).await {
|
||||||
|
deliver.line(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
mod platform {
|
||||||
|
use super::{AppDirs, Claim, Deliver, Listener, Request};
|
||||||
|
|
||||||
|
pub struct Inner;
|
||||||
|
|
||||||
|
pub fn claim(_: &tokio::runtime::Runtime, _: &AppDirs, _: &Request) -> Claim {
|
||||||
|
Claim::First(Listener { inner: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(_: Inner, _: &tokio::runtime::Runtime, _: Deliver) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#fragmentsecret";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_launch_with_a_share_link_asks_for_it_to_be_added() {
|
||||||
|
let args = ["--verbose".to_owned(), format!(" {LINK} ")];
|
||||||
|
assert_eq!(Request::from_args(args), Request::AddShare(LINK.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_launch_with_no_share_link_only_raises_the_window() {
|
||||||
|
let args = ["https://example.com/".to_owned()];
|
||||||
|
assert_eq!(Request::from_args(args), Request::Raise);
|
||||||
|
assert_eq!(Request::from_args(Vec::new()), Request::Raise);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_survives_the_wire() {
|
||||||
|
for request in [Request::Raise, Request::AddShare(LINK.to_owned())] {
|
||||||
|
assert_eq!(Request::decode(&request.encode()), Some(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_that_is_not_a_request_is_ignored() {
|
||||||
|
assert_eq!(Request::decode("add https://example.com/"), None);
|
||||||
|
assert_eq!(Request::decode("open the pod bay doors"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub mod app;
|
|||||||
pub mod desktop_prefs;
|
pub mod desktop_prefs;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod inhibit;
|
pub mod inhibit;
|
||||||
|
pub mod instance;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod pacing;
|
pub mod pacing;
|
||||||
|
|||||||
@@ -31,9 +31,27 @@ fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||||
|
|
||||||
|
// Before the window: a second launch hands its request to the first and
|
||||||
|
// never opens one.
|
||||||
|
let request = pstr_app::instance::Request::from_args(std::env::args().skip(1));
|
||||||
|
let instance = match pstr_app::instance::claim(&runtime, &dirs, &request) {
|
||||||
|
pstr_app::instance::Claim::First(listener) => listener,
|
||||||
|
pstr_app::instance::Claim::Handed => {
|
||||||
|
tracing::info!("handed over to the window already open");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let window = pstr_app::window::WindowState::load(&dirs.window_file());
|
let window = pstr_app::window::WindowState::load(&dirs.window_file());
|
||||||
|
let mut viewport = egui::ViewportBuilder::default();
|
||||||
|
// X11 and Windows take the icon from the window. Wayland ignores this and
|
||||||
|
// looks the app id up in the installed desktop entry instead.
|
||||||
|
match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) {
|
||||||
|
Ok(icon) => viewport = viewport.with_icon(icon),
|
||||||
|
Err(error) => tracing::debug!("window icon: {error}"),
|
||||||
|
}
|
||||||
let mut options = eframe::NativeOptions {
|
let mut options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: viewport
|
||||||
.with_title("proton-stream")
|
.with_title("proton-stream")
|
||||||
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
|
.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_maximized(window.is_some_and(|state| state.maximized))
|
||||||
@@ -50,7 +68,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
"proton-stream",
|
"proton-stream",
|
||||||
options,
|
options,
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
pstr_app::app::App::new(cc, runtime, dirs)
|
pstr_app::app::App::new(cc, runtime, dirs, instance, request)
|
||||||
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
||||||
.map_err(|error| error.into())
|
.map_err(|error| error.into())
|
||||||
}),
|
}),
|
||||||
|
|||||||
+108
-13
@@ -4,41 +4,48 @@
|
|||||||
//! app already has the toast, and a second copy of it from the panel is noise.
|
//! 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`.
|
//! That rule is the caller's — see `App::notify`.
|
||||||
//!
|
//!
|
||||||
//! Linux only, over `org.freedesktop.Notifications`. Windows wants an
|
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
|
||||||
//! application id registered by an installer before a toast will show, and a
|
//! macOS yet.
|
||||||
//! notification that silently never appears is worse than none.
|
//!
|
||||||
|
//! A Windows toast is shown under an application id, and Windows drops a toast
|
||||||
|
//! whose id it does not know without a word. The id is registered for the
|
||||||
|
//! current user under `HKCU\Software\Classes\AppUserModelId` — the same
|
||||||
|
//! registration the Windows App SDK makes for an app with no package — which
|
||||||
|
//! works for the MSI and the portable zip alike. If that write fails, the
|
||||||
|
//! toast goes out under PowerShell's id instead, which Windows always knows: a
|
||||||
|
//! notification that names the wrong program beats one that never appears.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// Sends notifications from a thread of its own, so a notification daemon
|
/// Sends notifications from a thread of its own, so a notification daemon
|
||||||
/// that is slow to answer never costs a frame.
|
/// that is slow to answer never costs a frame.
|
||||||
pub struct Notifier {
|
pub struct Notifier {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Notifier {
|
impl Notifier {
|
||||||
pub fn new() -> Self {
|
/// `cache` is where Windows is pointed for the toast's icon.
|
||||||
|
pub fn new(cache: &Path) -> Self {
|
||||||
|
let _ = cache;
|
||||||
Self {
|
Self {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
orders: linux::spawn(),
|
orders: linux::spawn(),
|
||||||
|
#[cfg(windows)]
|
||||||
|
orders: windows::spawn(cache.join("notification-icon.png")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
if let Some(orders) = &self.orders {
|
if let Some(orders) = &self.orders {
|
||||||
let _ = orders.send((summary.into(), body.into()));
|
let _ = orders.send((summary.into(), body.into()));
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
let _ = (summary.into(), body.into());
|
let _ = (summary.into(), body.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Notifier {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux {
|
mod linux {
|
||||||
use std::sync::mpsc::{Sender, channel};
|
use std::sync::mpsc::{Sender, channel};
|
||||||
@@ -93,3 +100,91 @@ mod linux {
|
|||||||
Some(orders)
|
Some(orders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
|
||||||
|
use tauri_winrt_notification::Toast;
|
||||||
|
|
||||||
|
/// The id toasts are shown under, and the key it is registered at.
|
||||||
|
const APP_ID: &str = "io.narl.proton-stream";
|
||||||
|
const ICON: &[u8] = include_bytes!("../assets/icon-256.png");
|
||||||
|
|
||||||
|
pub fn spawn(icon: PathBuf) -> Option<Sender<(String, String)>> {
|
||||||
|
let (orders, receiver) = channel::<(String, String)>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("notify".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Registered on first use rather than at startup: most
|
||||||
|
// sessions never send one.
|
||||||
|
let mut app_id: Option<&str> = None;
|
||||||
|
for (summary, body) in receiver {
|
||||||
|
let id = *app_id.get_or_insert_with(|| {
|
||||||
|
let _ = std::fs::write(&icon, ICON);
|
||||||
|
if register(&icon) {
|
||||||
|
APP_ID
|
||||||
|
} else {
|
||||||
|
Toast::POWERSHELL_APP_ID
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let shown = Toast::new(id).title(&summary).text1(&body).show();
|
||||||
|
if let Err(error) = shown {
|
||||||
|
tracing::debug!("notification: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell Windows the app id's display name and icon. Per user, no rights
|
||||||
|
/// needed, and written again every session so a moved install points at
|
||||||
|
/// a live icon.
|
||||||
|
fn register(icon: &Path) -> bool {
|
||||||
|
use windows_sys::Win32::System::Registry::{
|
||||||
|
HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
|
||||||
|
RegCreateKeyExW, RegSetValueExW,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn wide(text: &str) -> Vec<u16> {
|
||||||
|
text.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
|
||||||
|
let mut key: HKEY = std::ptr::null_mut();
|
||||||
|
// SAFETY: every pointer is to a live, NUL-terminated buffer or an out
|
||||||
|
// parameter on this stack frame, and the key is closed before return.
|
||||||
|
unsafe {
|
||||||
|
let created = RegCreateKeyExW(
|
||||||
|
HKEY_CURRENT_USER,
|
||||||
|
path.as_ptr(),
|
||||||
|
0,
|
||||||
|
std::ptr::null(),
|
||||||
|
REG_OPTION_NON_VOLATILE,
|
||||||
|
KEY_WRITE,
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut key,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if created != 0 {
|
||||||
|
tracing::debug!("register the notification id: error {created}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let icon = icon.to_string_lossy();
|
||||||
|
let mut written = true;
|
||||||
|
for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] {
|
||||||
|
let name = wide(name);
|
||||||
|
let value = wide(value);
|
||||||
|
let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX);
|
||||||
|
let set =
|
||||||
|
RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes);
|
||||||
|
written &= set == 0;
|
||||||
|
}
|
||||||
|
RegCloseKey(key);
|
||||||
|
written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-18
@@ -84,26 +84,151 @@ impl Role {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Headings are set in the semibold cut; everything below them in the
|
||||||
|
/// regular one.
|
||||||
|
pub fn family(self) -> egui::FontFamily {
|
||||||
|
match self {
|
||||||
|
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
||||||
|
_ => egui::FontFamily::Proportional,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// For the hand-painted parts, which lay text out through a `Painter` and
|
/// For the hand-painted parts, which lay text out through a `Painter` and
|
||||||
/// never see a `RichText`.
|
/// never see a `RichText`.
|
||||||
pub fn font(self) -> egui::FontId {
|
pub fn font(self) -> egui::FontId {
|
||||||
egui::FontId::proportional(self.size())
|
egui::FontId::new(self.size(), self.family())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For the parts that go through egui's widgets.
|
/// For the parts that go through egui's widgets.
|
||||||
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
||||||
egui::RichText::new(text).size(self.size())
|
egui::RichText::new(text)
|
||||||
|
.size(self.size())
|
||||||
|
.family(self.family())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install a real CJK fallback before the default egui fonts. egui ships a
|
/// The spacing scale. Gaps between things come from here rather than being
|
||||||
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
|
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
|
||||||
/// CJK families. Native desktops already provide one, so use it when present.
|
/// and never 14 in one place and 15 in the next.
|
||||||
|
pub mod space {
|
||||||
|
/// Between lines that belong to one thing: a title and its caption.
|
||||||
|
pub const XXS: f32 = 2.0;
|
||||||
|
pub const XS: f32 = 4.0;
|
||||||
|
/// Between controls in one cluster.
|
||||||
|
pub const S: f32 = 6.0;
|
||||||
|
pub const M: f32 = 8.0;
|
||||||
|
/// Between clusters on one row, and between rows of a form.
|
||||||
|
pub const L: f32 = 12.0;
|
||||||
|
/// Between the groups on a page.
|
||||||
|
pub const XL: f32 = 16.0;
|
||||||
|
/// Between sections.
|
||||||
|
pub const XXL: f32 = 24.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corner radii, as `egui::CornerRadius::same` takes them.
|
||||||
|
pub mod radius {
|
||||||
|
/// A progress bar, or a strip along an edge.
|
||||||
|
pub const BAR: u8 = 3;
|
||||||
|
/// A badge over artwork. Android's `Shape.kt` has the same number.
|
||||||
|
pub const BADGE: u8 = 4;
|
||||||
|
/// A small control inside something else: a menu entry, a chip.
|
||||||
|
pub const SM: u8 = 6;
|
||||||
|
/// Everything else either client paints: cards, buttons, the tab pill.
|
||||||
|
pub const MD: u8 = 8;
|
||||||
|
/// Something that floats: a row card, a dialog, a toast, the hero.
|
||||||
|
pub const LG: u8 = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long things take to change, in seconds, for `Context::animate_*`.
|
||||||
|
pub mod motion {
|
||||||
|
/// A press, which has to answer before the finger is off the button.
|
||||||
|
pub const PRESS: f32 = 0.05;
|
||||||
|
/// A hover arriving and leaving.
|
||||||
|
pub const HOVER: f32 = 0.12;
|
||||||
|
/// A control changing state: a switch flipping, the tab pill travelling,
|
||||||
|
/// one page giving way to the next.
|
||||||
|
pub const STATE: f32 = 0.15;
|
||||||
|
/// Chrome fading in and out: the player's controls, a toast.
|
||||||
|
pub const FADE: f32 = 0.18;
|
||||||
|
/// Artwork arriving over its placeholder. Slower, because it replaces
|
||||||
|
/// something rather than adding to it, and a fast swap reads as a flicker.
|
||||||
|
pub const ARRIVE: f32 = 0.24;
|
||||||
|
|
||||||
|
/// Fast out, slow in: the curve every hand-driven transition uses.
|
||||||
|
pub fn ease_out(t: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI typeface, bundled rather than taken from the system so a title
|
||||||
|
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
||||||
|
/// beside the files.
|
||||||
///
|
///
|
||||||
/// On a thread of its own: `fc-match` and reading a font file of tens of
|
/// Both files have their private use area mappings removed, because the
|
||||||
/// megabytes held the first frame back by a visible moment, and every name
|
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
||||||
/// that needs the fallback is still loading from the catalog by then anyway.
|
/// own alternate glyphs, and as the first font in the family it won: the
|
||||||
pub fn install_font_fallbacks(ctx: &egui::Context) {
|
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
||||||
|
/// in the files; only the character map entries are gone. Strip them again
|
||||||
|
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
|
||||||
|
/// U+E000..=U+F8FF).
|
||||||
|
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
||||||
|
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
||||||
|
|
||||||
|
/// The family the headings are set in. egui has no notion of weight, so the
|
||||||
|
/// semibold cut is a family of its own.
|
||||||
|
pub fn semibold() -> egui::FontFamily {
|
||||||
|
egui::FontFamily::Name("semibold".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
|
||||||
|
/// symbols Inter does not carry. `cjk` goes last, when there is one.
|
||||||
|
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||||
|
use egui::FontFamily::{Monospace, Proportional};
|
||||||
|
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert("inter".into(), Arc::new(egui::FontData::from_static(INTER)));
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"inter-semibold".into(),
|
||||||
|
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
||||||
|
);
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "inter".into());
|
||||||
|
// Right behind Inter. The icons are in the private use area, which the
|
||||||
|
// bundled Inter no longer maps, so neither font shadows the other.
|
||||||
|
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||||
|
if let Some(cjk) = cjk {
|
||||||
|
let name = "system-cjk-fallback".to_owned();
|
||||||
|
fonts.font_data.insert(name.clone(), Arc::new(cjk));
|
||||||
|
for family in [Proportional, Monospace] {
|
||||||
|
fonts.families.entry(family).or_default().push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fallbacks = fonts.families[&Proportional]
|
||||||
|
.iter()
|
||||||
|
.filter(|name| *name != "inter")
|
||||||
|
.cloned();
|
||||||
|
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
||||||
|
.chain(fallbacks)
|
||||||
|
.collect();
|
||||||
|
fonts.families.insert(semibold(), semibold_chain);
|
||||||
|
fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the bundled fonts now, and a CJK fallback when the system has one.
|
||||||
|
///
|
||||||
|
/// The bundled ones are compiled in and cost nothing to hand over. The CJK
|
||||||
|
/// fallback is on a thread of its own: `fc-match` and reading a font file of
|
||||||
|
/// tens of megabytes held the first frame back by a visible moment, and every
|
||||||
|
/// name that needs the fallback is still loading from the catalog by then
|
||||||
|
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
||||||
|
/// and native desktops already provide one.
|
||||||
|
pub fn install_fonts(ctx: &egui::Context) {
|
||||||
|
ctx.set_fonts(font_definitions(None));
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let spawned = std::thread::Builder::new()
|
let spawned = std::thread::Builder::new()
|
||||||
.name("fonts".into())
|
.name("fonts".into())
|
||||||
@@ -120,16 +245,9 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
|||||||
let Ok(bytes) = std::fs::read(path) else {
|
let Ok(bytes) = std::fs::read(path) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
data.index = index;
|
data.index = index;
|
||||||
let name = "system-cjk-fallback".to_owned();
|
ctx.set_fonts(font_definitions(Some(data)));
|
||||||
fonts.font_data.insert(name.clone(), Arc::new(data));
|
|
||||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
|
||||||
fonts.families.entry(family).or_default().push(name.clone());
|
|
||||||
}
|
|
||||||
ctx.set_fonts(fonts);
|
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +687,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
|||||||
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
||||||
visuals.hyperlink_color = palette.accent;
|
visuals.hyperlink_color = palette.accent;
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(radius::MD);
|
||||||
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
||||||
visuals.widgets.noninteractive.corner_radius = radius;
|
visuals.widgets.noninteractive.corner_radius = radius;
|
||||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
||||||
@@ -731,6 +849,27 @@ const RAMP_STOPS: usize = 64;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||||
|
use skrifa::MetadataProvider as _;
|
||||||
|
|
||||||
|
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
||||||
|
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
||||||
|
let shadowed: Vec<u32> = font
|
||||||
|
.charmap()
|
||||||
|
.mappings()
|
||||||
|
.map(|(codepoint, _)| codepoint)
|
||||||
|
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
shadowed.is_empty(),
|
||||||
|
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
|
||||||
|
shadowed.len(),
|
||||||
|
shadowed[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
||||||
let resolved = Palette::resolve(Appearance::default());
|
let resolved = Palette::resolve(Appearance::default());
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_max_width(960.0);
|
ui.set_max_width(960.0);
|
||||||
summary(ui, downloads);
|
summary(ui, downloads);
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
let mut start = 0;
|
let mut start = 0;
|
||||||
while start < downloads.len() {
|
while start < downloads.len() {
|
||||||
@@ -30,7 +30,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
|
|||||||
.position(|item| item.target.title_key != *title_key)
|
.position(|item| item.target.title_key != *title_key)
|
||||||
.map_or(downloads.len(), |offset| start + offset);
|
.map_or(downloads.len(), |offset| start + offset);
|
||||||
group(ui, &downloads[start..end], actions);
|
group(ui, &downloads[start..end], actions);
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
start = end;
|
start = end;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,7 @@ fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
|
|||||||
if totals_known && group.len() > 1 {
|
if totals_known && group.len() > 1 {
|
||||||
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
|
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
for item in group {
|
for item in group {
|
||||||
row(ui, item, actions);
|
row(ui, item, actions);
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
|
|||||||
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
|
||||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
@@ -142,7 +142,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
|||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
buttons(ui, item, actions);
|
buttons(ui, item, actions);
|
||||||
if item.total > 0 && item.state != DownloadState::Completed {
|
if item.total > 0 && item.state != DownloadState::Completed {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.label(ui::muted(format!(
|
ui.label(ui::muted(format!(
|
||||||
"{} / {}",
|
"{} / {}",
|
||||||
size(item.downloaded),
|
size(item.downloaded),
|
||||||
@@ -154,7 +154,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a row says about itself, and in what colour.
|
/// What a row says about itself, and in what colour.
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! Everything that has been played, newest first, under a heading per day.
|
||||||
|
//!
|
||||||
|
//! Continue watching answers "what was I in the middle of"; this answers
|
||||||
|
//! "what was that episode on Tuesday". It is read straight off the watch
|
||||||
|
//! state, so there is nothing to keep in step: an episode is here because it
|
||||||
|
//! has a position or is marked watched, and leaves when it has neither.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local, NaiveDate, TimeZone};
|
||||||
|
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||||
|
|
||||||
|
use crate::app::{Action, Page};
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui::{self, Art};
|
||||||
|
|
||||||
|
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||||
|
/// How far back the page goes. Further than anyone scrolls; the cap is there
|
||||||
|
/// so a library with years of history does not lay out every row of it.
|
||||||
|
const MOST: usize = 400;
|
||||||
|
|
||||||
|
/// Where one entry lives in [`Library::titles`]: title, season, episode.
|
||||||
|
pub type Entry = (usize, usize, usize);
|
||||||
|
|
||||||
|
/// The entries of `library.history()`, as positions, so they can be kept
|
||||||
|
/// between frames and only rebuilt when the library changes.
|
||||||
|
pub fn entries(library: &Library) -> Vec<Entry> {
|
||||||
|
let mut position = std::collections::HashMap::new();
|
||||||
|
for (t, title) in library.titles.iter().enumerate() {
|
||||||
|
for (s, season) in title.seasons.iter().enumerate() {
|
||||||
|
for (e, episode) in season.episodes.iter().enumerate() {
|
||||||
|
position.insert(std::ptr::from_ref(episode), (t, s, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
library
|
||||||
|
.history()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, episode)| position.get(&std::ptr::from_ref(episode)).copied())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
library: &Library,
|
||||||
|
entries: &[Entry],
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
if entries.is_empty() {
|
||||||
|
ui::empty_state(
|
||||||
|
ui,
|
||||||
|
"Nothing watched yet",
|
||||||
|
"Episodes and films you play show up here, newest first, so the one from \
|
||||||
|
last week is easy to find again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_max_width(960.0);
|
||||||
|
let mut day = None;
|
||||||
|
for &(t, s, e) in entries.iter().take(MOST) {
|
||||||
|
let Some(title) = library.titles.get(t) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(episode) = title
|
||||||
|
.seasons
|
||||||
|
.get(s)
|
||||||
|
.and_then(|season| season.episodes.get(e))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(at) = Local.timestamp_opt(episode.last_played(), 0).single() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if day != Some(at.date_naive()) {
|
||||||
|
if day.is_some() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
|
day = Some(at.date_naive());
|
||||||
|
ui::section(ui, &day_label(at.date_naive(), today));
|
||||||
|
}
|
||||||
|
row(ui, art, title, episode, at, actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
episode: &Episode,
|
||||||
|
at: DateTime<Local>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let row_id = ui.id().with(("history", &episode.node.link_id));
|
||||||
|
let frame = egui::Frame::new()
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::same(8));
|
||||||
|
let mut prepared = frame.begin(ui);
|
||||||
|
{
|
||||||
|
let ui = &mut prepared.content_ui;
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui::title::still(
|
||||||
|
ui,
|
||||||
|
art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
episode.is_watched(),
|
||||||
|
STILL,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
|
let remove = 40.0;
|
||||||
|
let text_width = (ui.available_width() - remove).max(120.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(text_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = theme::space::XXS;
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
let name = ui
|
||||||
|
.add(
|
||||||
|
egui::Label::new(theme::Role::Body.rich(&title.name).color(theme::text()))
|
||||||
|
.sense(egui::Sense::click())
|
||||||
|
.truncate(),
|
||||||
|
)
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Show this title");
|
||||||
|
if name.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
// A film's only "episode" is its file, and its filename says
|
||||||
|
// nothing the title above it does not.
|
||||||
|
if title.kind != TitleKind::Film {
|
||||||
|
let episode_name = art
|
||||||
|
.episode(&title.key, episode)
|
||||||
|
.and_then(|found| found.name.clone())
|
||||||
|
.unwrap_or_else(|| episode.detail().to_owned());
|
||||||
|
let line = match episode.numbering() {
|
||||||
|
Some(numbering) => format!("{numbering} · {episode_name}"),
|
||||||
|
None => episode_name,
|
||||||
|
};
|
||||||
|
ui.add(egui::Label::new(ui::muted(line)).truncate())
|
||||||
|
.on_hover_text(&episode.node.name);
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(status(episode, at)));
|
||||||
|
});
|
||||||
|
|
||||||
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
|
let remove = ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::X)
|
||||||
|
.color(theme::muted()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.on_hover_text(if episode.is_watched() {
|
||||||
|
"Remove from history — marks it unwatched"
|
||||||
|
} else {
|
||||||
|
"Remove from history — forgets where you stopped"
|
||||||
|
});
|
||||||
|
if remove.clicked() {
|
||||||
|
actions.push(Action::RemoveFromHistory {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let response = prepared.allocate_space(ui);
|
||||||
|
let lit =
|
||||||
|
ui.ctx()
|
||||||
|
.animate_bool_with_time(row_id, response.contains_pointer(), theme::motion::HOVER);
|
||||||
|
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
||||||
|
prepared.paint(ui);
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grey line under the names: how far it got, and when.
|
||||||
|
fn status(episode: &Episode, at: DateTime<Local>) -> String {
|
||||||
|
let when = at.format("%H:%M");
|
||||||
|
if episode.is_watched() {
|
||||||
|
return format!("Watched · {when}");
|
||||||
|
}
|
||||||
|
match (
|
||||||
|
episode.resume_at(),
|
||||||
|
episode.watch.and_then(|watch| watch.duration_secs),
|
||||||
|
) {
|
||||||
|
(Some(position), Some(duration)) => format!(
|
||||||
|
"Stopped at {} of {} · {when}",
|
||||||
|
ui::format_time(position),
|
||||||
|
ui::format_time(duration)
|
||||||
|
),
|
||||||
|
(Some(position), None) => format!("Stopped at {} · {when}", ui::format_time(position)),
|
||||||
|
_ => format!("Played · {when}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading over a day's entries: "Today", "Yesterday", a weekday within
|
||||||
|
/// the last week, and a date before that — with the year only when it is not
|
||||||
|
/// this one.
|
||||||
|
fn day_label(day: NaiveDate, today: NaiveDate) -> String {
|
||||||
|
use chrono::Datelike;
|
||||||
|
match (today - day).num_days() {
|
||||||
|
0 => "Today".into(),
|
||||||
|
1 => "Yesterday".into(),
|
||||||
|
2..=6 => day.format("%A").to_string(),
|
||||||
|
_ if day.year() == today.year() => day.format("%-d %B").to_string(),
|
||||||
|
_ => day.format("%-d %B %Y").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_day_is_named_relative_to_today_for_the_last_week_and_dated_before_that() {
|
||||||
|
let today = date(2026, 10, 2);
|
||||||
|
assert_eq!(day_label(today, today), "Today");
|
||||||
|
assert_eq!(day_label(date(2026, 10, 1), today), "Yesterday");
|
||||||
|
assert_eq!(day_label(date(2026, 9, 28), today), "Monday");
|
||||||
|
assert_eq!(day_label(date(2026, 9, 25), today), "25 September");
|
||||||
|
assert_eq!(day_label(date(2025, 12, 24), today), "24 December 2025");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,13 +47,13 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
if !searching {
|
if !searching {
|
||||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||||
hero(ui, art, title, actions);
|
hero(ui, art, title, actions);
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
let resumable = pick(&view.resumable);
|
let resumable = pick(&view.resumable);
|
||||||
if !resumable.is_empty() {
|
if !resumable.is_empty() {
|
||||||
ui::section(ui, "Continue watching");
|
ui::section(ui, "Continue watching");
|
||||||
continue_row(ui, art, &resumable, actions);
|
continue_row(ui, art, &resumable, actions);
|
||||||
ui.add_space(16.0);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +74,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
shelf_controls(ui, view, actions);
|
shelf_controls(ui, view, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.label(ui::muted(if searching {
|
ui.label(ui::muted(if searching {
|
||||||
"Nothing here by that name."
|
"Nothing here by that name."
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +143,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let radius = egui::CornerRadius::same(12);
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
let picture = art.of(title);
|
let picture = art.of(title);
|
||||||
let found = art
|
let found = art
|
||||||
.metadata
|
.metadata
|
||||||
@@ -152,9 +152,11 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
|
|
||||||
let painter = ui.painter().with_clip_rect(rect);
|
let painter = ui.painter().with_clip_rect(rect);
|
||||||
painter.rect_filled(rect, radius, theme::card());
|
painter.rect_filled(rect, radius, theme::card());
|
||||||
let art_in = ui
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
.ctx()
|
response.id.with("art"),
|
||||||
.animate_bool_with_time(response.id.with("art"), picture.is_some(), 0.3);
|
picture.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
if art_in < 1.0 {
|
if art_in < 1.0 {
|
||||||
ui::placeholder(&painter, rect, "", 1.0 - art_in);
|
ui::placeholder(&painter, rect, "", 1.0 - art_in);
|
||||||
}
|
}
|
||||||
@@ -211,20 +213,22 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(episode) = title.next_up() {
|
if let Some(episode) = title.next_up() {
|
||||||
let label = match (title.resume(), episode.numbering()) {
|
let label = match (title.resume(), episode.numbering()) {
|
||||||
(Some(_), Some(numbering)) => format!("▶ Resume {numbering}"),
|
(Some(_), Some(numbering)) => {
|
||||||
(Some(_), None) => "▶ Resume".to_owned(),
|
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
|
||||||
(None, _) => "▶ Play".to_owned(),
|
}
|
||||||
|
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
|
||||||
|
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
|
||||||
};
|
};
|
||||||
if ui::accent_button(ui, &label).clicked() {
|
if ui::accent_button(ui, &label).clicked() {
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
if ui.button("More info").clicked() {
|
if ui.button("More info").clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
|
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
|
||||||
let mut job = egui::text::LayoutJob::simple(
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
overview.to_owned(),
|
overview.to_owned(),
|
||||||
@@ -234,17 +238,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
);
|
);
|
||||||
job.wrap.max_rows = 3;
|
job.wrap.max_rows = 3;
|
||||||
ui.label(job);
|
ui.label(job);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
}
|
}
|
||||||
let mut facts = vec![subtitle(title)];
|
let mut facts = vec![subtitle(title)];
|
||||||
if let Some(found) = &found {
|
if let Some(found) = &found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
facts.push(format!("★ {rating:.1}"));
|
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
facts.extend(found.genres.iter().take(3).cloned());
|
facts.extend(found.genres.iter().take(3).cloned());
|
||||||
}
|
}
|
||||||
ui.label(ui::muted(facts.join(" · ")));
|
ui.label(ui::muted(facts.join(" · ")));
|
||||||
ui.add_space(2.0);
|
ui.add_space(theme::space::XXS);
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
theme::Role::Display
|
theme::Role::Display
|
||||||
@@ -288,7 +292,7 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
|||||||
if let Some(Some(sort)) = sort.inner {
|
if let Some(Some(sort)) = sort.inner {
|
||||||
actions.push(Action::SetShelf(view.filter, sort));
|
actions.push(Action::SetShelf(view.filter, sort));
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
if let Some(filter) = ui::widgets::segmented(
|
if let Some(filter) = ui::widgets::segmented(
|
||||||
ui,
|
ui,
|
||||||
view.filter,
|
view.filter,
|
||||||
@@ -525,8 +529,11 @@ fn placeholder(ui: &mut egui::Ui) {
|
|||||||
rect.min,
|
rect.min,
|
||||||
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
|
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
|
||||||
);
|
);
|
||||||
ui.painter()
|
ui.painter().rect_filled(
|
||||||
.rect_filled(image, egui::CornerRadius::same(8), theme::card());
|
image,
|
||||||
|
egui::CornerRadius::same(theme::radius::MD),
|
||||||
|
theme::card(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(theme::CARD_GAP);
|
ui.add_space(theme::CARD_GAP);
|
||||||
@@ -541,11 +548,11 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
|||||||
.rich("Nothing in the library yet")
|
.rich("Nothing in the library yet")
|
||||||
.strong(),
|
.strong(),
|
||||||
);
|
);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||||
));
|
));
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
if ui::accent_button(ui, "Add a share").clicked() {
|
if ui::accent_button(ui, "Add a share").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::Goto(Page::Shares));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub fn show(
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::surface())
|
.fill(theme::surface())
|
||||||
.inner_margin(egui::Margin::same(18))
|
.inner_margin(egui::Margin::same(18))
|
||||||
.corner_radius(egui::CornerRadius::same(10)),
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
|
||||||
)
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(620.0);
|
ui.set_width(620.0);
|
||||||
@@ -58,21 +58,21 @@ pub fn show(
|
|||||||
"Everything {} answers with, in its own order — nothing is scored here.",
|
"Everything {} answers with, in its own order — nothing is scored here.",
|
||||||
provider.label()
|
provider.label()
|
||||||
)));
|
)));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
search_row(ui, matcher, actions);
|
search_row(ui, matcher, actions);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
current_row(ui, matcher, current.as_ref(), actions);
|
current_row(ui, matcher, current.as_ref(), actions);
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
if let Some(error) = &matcher.error {
|
if let Some(error) = &matcher.error {
|
||||||
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
results(ui, matcher, art, actions);
|
results(ui, matcher, art, actions);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if ui.button("Close").clicked() {
|
if ui.button("Close").clicked() {
|
||||||
@@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
|
|||||||
if row(ui, art, found).clicked() {
|
if row(ui, art, found).clicked() {
|
||||||
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
|
|||||||
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
||||||
let response = egui::Frame::new()
|
let response = egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
|
||||||
.inner_margin(egui::Margin::same(8))
|
.inner_margin(egui::Margin::same(8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
@@ -204,12 +204,12 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
} else {
|
} else {
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::CornerRadius::same(4),
|
egui::CornerRadius::same(theme::radius::BADGE),
|
||||||
theme::card_hover(),
|
theme::card_hover(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Body
|
theme::Role::Body
|
||||||
@@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
response.rect,
|
response.rect,
|
||||||
egui::CornerRadius::same(6),
|
egui::CornerRadius::same(theme::radius::SM),
|
||||||
egui::Stroke::new(1.5, theme::accent()),
|
egui::Stroke::new(1.5, theme::accent()),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
@@ -279,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
|
|||||||
parts.push(ui::library::plural(episodes as usize, "episode"));
|
parts.push(ui::library::plural(episodes as usize, "episode"));
|
||||||
}
|
}
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("★ {rating:.1}"));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
pub mod history;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
@@ -141,14 +142,14 @@ pub fn format_size(bytes: i64) -> String {
|
|||||||
|
|
||||||
/// A section title with a rule under it.
|
/// A section title with a rule under it.
|
||||||
pub fn section(ui: &mut egui::Ui, text: &str) {
|
pub fn section(ui: &mut egui::Ui, text: &str) {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Section
|
theme::Role::Section
|
||||||
.rich(text)
|
.rich(text)
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
ui.add_space(2.0);
|
ui.add_space(theme::space::XXS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Small grey text, for everything that is context rather than content.
|
/// Small grey text, for everything that is context rather than content.
|
||||||
@@ -173,7 +174,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
|||||||
let has_focus = ui.memory(|memory| memory.has_focus(id));
|
let has_focus = ui.memory(|memory| memory.has_focus(id));
|
||||||
let lit = ui
|
let lit = ui
|
||||||
.ctx()
|
.ctx()
|
||||||
.animate_bool_with_time(id.with("lit"), has_focus, 0.12);
|
.animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER);
|
||||||
|
|
||||||
let radius = CornerRadius::same((height / 2.0) as u8);
|
let radius = CornerRadius::same((height / 2.0) as u8);
|
||||||
ui.painter().rect_filled(outer, radius, theme::card());
|
ui.painter().rect_filled(outer, radius, theme::card());
|
||||||
@@ -258,7 +259,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
|||||||
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
|
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());
|
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let radius = CornerRadius::same(3);
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
||||||
let mut done = rect;
|
let mut done = rect;
|
||||||
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
|
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
|
||||||
@@ -275,7 +276,7 @@ pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
|
|||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.add_space(96.0);
|
ui.add_space(96.0);
|
||||||
ui.label(theme::Role::Title.rich(heading).strong());
|
ui.label(theme::Role::Title.rich(heading).strong());
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(muted(body));
|
ui.label(muted(body));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -311,7 +312,7 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::surface())
|
.fill(theme::surface())
|
||||||
.inner_margin(egui::Margin::same(18))
|
.inner_margin(egui::Margin::same(18))
|
||||||
.corner_radius(CornerRadius::same(10)),
|
.corner_radius(CornerRadius::same(theme::radius::LG)),
|
||||||
)
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(420.0);
|
ui.set_width(420.0);
|
||||||
@@ -321,9 +322,9 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
|
|||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||||
ui.add_space(16.0);
|
ui.add_space(theme::space::XL);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if danger_button(ui, verb).clicked() {
|
if danger_button(ui, verb).clicked() {
|
||||||
answer = Answer::Confirmed;
|
answer = Answer::Confirmed;
|
||||||
@@ -382,15 +383,17 @@ pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<u
|
|||||||
// the pill from overshooting when it moves between tabs of different
|
// the pill from overshooting when it moves between tabs of different
|
||||||
// lengths — both edges arrive at the same time.
|
// lengths — both edges arrive at the same time.
|
||||||
let ctx = ui.ctx();
|
let ctx = ui.ctx();
|
||||||
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16);
|
let left =
|
||||||
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16);
|
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
|
||||||
|
let right =
|
||||||
|
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
|
||||||
let rect = Rect::from_min_max(
|
let rect = Rect::from_min_max(
|
||||||
egui::pos2(left, target.top()),
|
egui::pos2(left, target.top()),
|
||||||
egui::pos2(right, target.bottom()),
|
egui::pos2(right, target.bottom()),
|
||||||
);
|
);
|
||||||
ui.painter().set(
|
ui.painter().set(
|
||||||
pill,
|
pill,
|
||||||
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)),
|
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,17 +434,21 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
// Eased rather than switched, so the veil arrives over a few frames instead
|
// Eased rather than switched, so the veil arrives over a few frames instead
|
||||||
// of appearing whole the moment the pointer crosses the edge. The press is
|
// of appearing whole the moment the pointer crosses the edge. The press is
|
||||||
// quicker than the hover: a click should feel like it registered, not like
|
// quicker than the hover: a click should feel like it registered, not like
|
||||||
// it was considered.
|
// it was considered.
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
let press = ctx.animate_bool_with_time(
|
let press = ctx.animate_bool_with_time(
|
||||||
response.id.with("press"),
|
response.id.with("press"),
|
||||||
response.is_pointer_button_down_on(),
|
response.is_pointer_button_down_on(),
|
||||||
0.05,
|
theme::motion::PRESS,
|
||||||
);
|
);
|
||||||
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
|
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
|
||||||
|
|
||||||
@@ -510,7 +517,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
let width = card.width;
|
let width = card.width;
|
||||||
let image_height = (width * theme::CARD_ASPECT).round();
|
let image_height = (width * theme::CARD_ASPECT).round();
|
||||||
let text_height = CARD_TEXT_HEIGHT;
|
let text_height = CARD_TEXT_HEIGHT;
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
|
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||||
@@ -533,14 +540,16 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
let hover = ui.ctx().animate_bool_with_time(
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
response.id.with("hover"),
|
response.id.with("hover"),
|
||||||
response.hovered() || response.has_focus(),
|
response.hovered() || response.has_focus(),
|
||||||
0.12,
|
theme::motion::HOVER,
|
||||||
);
|
);
|
||||||
// The picture arrives whenever its download finishes, several frames after
|
// The picture arrives whenever its download finishes, several frames after
|
||||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||||
// letters popping into stills into one settle.
|
// letters popping into stills into one settle.
|
||||||
let art_in = ui
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
.ctx()
|
response.id.with("art"),
|
||||||
.animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22);
|
card.art.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
if hover > 0.0 {
|
if hover > 0.0 {
|
||||||
@@ -591,7 +600,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
);
|
);
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
background,
|
background,
|
||||||
CornerRadius::same(4),
|
CornerRadius::same(theme::radius::BADGE),
|
||||||
Color32::from_black_alpha(180),
|
Color32::from_black_alpha(180),
|
||||||
);
|
);
|
||||||
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
|
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
|
||||||
@@ -724,7 +733,7 @@ pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opaci
|
|||||||
theme::ramp_fill(
|
theme::ramp_fill(
|
||||||
painter,
|
painter,
|
||||||
rect,
|
rect,
|
||||||
CornerRadius::same(8),
|
CornerRadius::same(theme::radius::MD),
|
||||||
(from, to),
|
(from, to),
|
||||||
theme::Direction::Vertical,
|
theme::Direction::Vertical,
|
||||||
opacity,
|
opacity,
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const SKIP_PAD_X: f32 = 22.0;
|
|||||||
/// on the full wash. See [`scrim_bottom`].
|
/// on the full wash. See [`scrim_bottom`].
|
||||||
const SCRIM_ALPHA: f32 = 170.0;
|
const SCRIM_ALPHA: f32 = 170.0;
|
||||||
const SCRIM_FADE: f32 = 88.0;
|
const SCRIM_FADE: f32 = 88.0;
|
||||||
|
/// How wide the episode list is, at most. A narrow window gives it less.
|
||||||
|
const DRAWER_WIDTH: f32 = 380.0;
|
||||||
|
/// The stills in the episode list.
|
||||||
|
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
|
||||||
|
|
||||||
/// State the page keeps between frames.
|
/// State the page keeps between frames.
|
||||||
///
|
///
|
||||||
@@ -109,6 +113,17 @@ pub struct Chrome<'a> {
|
|||||||
pub up_next: Option<UpNextCard>,
|
pub up_next: Option<UpNextCard>,
|
||||||
/// Whether stepping to another episode is possible in either direction.
|
/// Whether stepping to another episode is possible in either direction.
|
||||||
pub neighbours: Neighbours,
|
pub neighbours: Neighbours,
|
||||||
|
/// The title's episodes, when it has more than one — what the list down
|
||||||
|
/// the right of the picture shows.
|
||||||
|
pub episodes: Option<Episodes<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The playing title's episodes, for the list beside the picture.
|
||||||
|
pub struct Episodes<'a> {
|
||||||
|
pub title: &'a pstr_core::library::Title,
|
||||||
|
pub art: ui::Art<'a>,
|
||||||
|
/// Whether the list is out.
|
||||||
|
pub open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the end-of-episode card says, when there is one.
|
/// What the end-of-episode card says, when there is one.
|
||||||
@@ -187,6 +202,7 @@ pub fn show(
|
|||||||
overlay,
|
overlay,
|
||||||
up_next,
|
up_next,
|
||||||
neighbours,
|
neighbours,
|
||||||
|
mut episodes,
|
||||||
} = chrome_state;
|
} = chrome_state;
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
@@ -228,7 +244,10 @@ pub fn show(
|
|||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
let visible = overlay.visible(&ctx, playback);
|
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
|
||||||
|
// The controls stay up while the list is out: it hangs off the button in
|
||||||
|
// the title strip, and is read at leisure.
|
||||||
|
let visible = overlay.visible(&ctx, playback) || drawer_open;
|
||||||
// Above the chrome when it is up, at the bottom corner when it is not —
|
// Above the chrome when it is up, at the bottom corner when it is not —
|
||||||
// and clear of it either way, which is what the measured height buys.
|
// and clear of it either way, which is what the measured height buys.
|
||||||
let float_above = if visible {
|
let float_above = if visible {
|
||||||
@@ -251,7 +270,7 @@ pub fn show(
|
|||||||
|
|
||||||
// Faded rather than switched, both ways: controls that blink out under a
|
// Faded rather than switched, both ways: controls that blink out under a
|
||||||
// reading eye are more distracting than the ones that were there.
|
// 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);
|
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE);
|
||||||
if fade > 0.0 {
|
if fade > 0.0 {
|
||||||
overlay.chrome_height = ui
|
overlay.chrome_height = ui
|
||||||
.scope(|ui| {
|
.scope(|ui| {
|
||||||
@@ -260,6 +279,7 @@ pub fn show(
|
|||||||
ui,
|
ui,
|
||||||
playback,
|
playback,
|
||||||
neighbours,
|
neighbours,
|
||||||
|
episodes.as_ref().map(|list| list.open),
|
||||||
rect,
|
rect,
|
||||||
overlay.chrome_height,
|
overlay.chrome_height,
|
||||||
actions,
|
actions,
|
||||||
@@ -267,6 +287,11 @@ pub fn show(
|
|||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
}
|
}
|
||||||
|
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
|
||||||
|
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
|
||||||
|
} else {
|
||||||
|
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
|
||||||
|
}
|
||||||
if !visible {
|
if !visible {
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||||
// The controls have to disappear on their own, and nothing else is
|
// The controls have to disappear on their own, and nothing else is
|
||||||
@@ -379,7 +404,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
|||||||
if ui.button("Back").clicked() {
|
if ui.button("Back").clicked() {
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -479,7 +504,7 @@ fn up_next_card(
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(Color32::from_black_alpha(215))
|
.fill(Color32::from_black_alpha(215))
|
||||||
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::same(12))
|
.inner_margin(egui::Margin::same(12))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(size.x - 24.0);
|
ui.set_width(size.x - 24.0);
|
||||||
@@ -488,7 +513,7 @@ fn up_next_card(
|
|||||||
// coming and when, read in one glance.
|
// coming and when, read in one glance.
|
||||||
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
|
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let radius = CornerRadius::same(6);
|
let radius = CornerRadius::same(theme::radius::SM);
|
||||||
match &card.still {
|
match &card.still {
|
||||||
Some(texture) => {
|
Some(texture) => {
|
||||||
painter.add(
|
painter.add(
|
||||||
@@ -518,7 +543,7 @@ fn up_next_card(
|
|||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Caption
|
theme::Role::Caption
|
||||||
@@ -535,12 +560,12 @@ fn up_next_card(
|
|||||||
)
|
)
|
||||||
.truncate(),
|
.truncate(),
|
||||||
);
|
);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui::accent_button(ui, "Play now").clicked() {
|
if ui::accent_button(ui, "Play now").clicked() {
|
||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
if ui
|
if ui
|
||||||
.button("Watch till the end")
|
.button("Watch till the end")
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
.on_hover_text("Play this one out — nothing will be skipped")
|
||||||
@@ -618,6 +643,7 @@ fn chrome(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
|
drawer: Option<bool>,
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
previous_height: f32,
|
previous_height: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
@@ -633,7 +659,7 @@ fn chrome(
|
|||||||
.add(
|
.add(
|
||||||
egui::Button::new(theme::Role::Label.rich("Library"))
|
egui::Button::new(theme::Role::Label.rich("Library"))
|
||||||
.fill(Color32::from_black_alpha(160))
|
.fill(Color32::from_black_alpha(160))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(96.0, 32.0)),
|
.min_size(Vec2::new(96.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
||||||
@@ -641,7 +667,7 @@ fn chrome(
|
|||||||
{
|
{
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Heading
|
theme::Role::Heading
|
||||||
@@ -657,18 +683,56 @@ fn chrome(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(theme::Role::Subhead.rich("⛶"))
|
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
|
||||||
.fill(Color32::from_black_alpha(160))
|
egui_phosphor::regular::CORNERS_IN
|
||||||
.corner_radius(CornerRadius::same(8))
|
} else {
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
egui_phosphor::regular::CORNERS_OUT
|
||||||
|
}))
|
||||||
|
.fill(Color32::from_black_alpha(160))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Fullscreen (F)")
|
.on_hover_text(if fullscreen {
|
||||||
|
"Leave fullscreen (F)"
|
||||||
|
} else {
|
||||||
|
"Fullscreen (F)"
|
||||||
|
})
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
if let Some(open) = drawer {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let fill = if open {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
Color32::from_black_alpha(160)
|
||||||
|
};
|
||||||
|
let ink = if open {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
Color32::WHITE
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::LIST_BULLETS)
|
||||||
|
.color(ink),
|
||||||
|
)
|
||||||
|
.fill(fill)
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Episodes (E)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -707,6 +771,216 @@ fn chrome(
|
|||||||
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
|
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which file the episode list last scrolled to, in egui's memory, so it
|
||||||
|
/// scrolls to the playing one once when it opens rather than every frame.
|
||||||
|
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
|
||||||
|
|
||||||
|
/// The title's episodes, down the right of the picture between the title
|
||||||
|
/// strip and the controls. A season at a time, opened on the playing one.
|
||||||
|
fn episode_drawer(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
list: &mut Episodes<'_>,
|
||||||
|
playback: &Playback,
|
||||||
|
rect: Rect,
|
||||||
|
chrome_height: f32,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
|
||||||
|
let area = Rect::from_min_max(
|
||||||
|
egui::pos2(rect.right() - width - 16.0, rect.top() + TITLE_HEIGHT),
|
||||||
|
egui::pos2(rect.right() - 16.0, rect.bottom() - chrome_height - 8.0),
|
||||||
|
);
|
||||||
|
if area.height() < 160.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let title = list.title;
|
||||||
|
let target = &playback.target;
|
||||||
|
let is_playing = |episode: &pstr_core::library::Episode| {
|
||||||
|
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
|
||||||
|
};
|
||||||
|
let playing_season = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.position(|season| season.episodes.iter().any(is_playing))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let season_id = egui::Id::new(("episode-drawer-season", &title.key));
|
||||||
|
let mut season = ctx
|
||||||
|
.data(|data| data.get_temp::<usize>(season_id))
|
||||||
|
.unwrap_or(playing_season)
|
||||||
|
.min(title.seasons.len().saturating_sub(1));
|
||||||
|
|
||||||
|
let margin = theme::space::L;
|
||||||
|
egui::Area::new(egui::Id::new("episode-drawer"))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(area.min)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface().gamma_multiply(0.96))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::same(margin as i8))
|
||||||
|
.shadow(theme::tile_shadow(1.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(area.width() - 2.0 * margin);
|
||||||
|
ui.set_height(area.height() - 2.0 * margin);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::X)
|
||||||
|
.color(theme::muted()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.on_hover_text("Close (E)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if title.seasons.len() > 1 {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
let labels: Vec<(usize, String)> = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, season)| (index, season.label()))
|
||||||
|
.collect();
|
||||||
|
let choices: Vec<(usize, &str)> = labels
|
||||||
|
.iter()
|
||||||
|
.map(|(index, label)| (*index, label.as_str()))
|
||||||
|
.collect();
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt("drawer-seasons")
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
|
||||||
|
season = picked;
|
||||||
|
ctx.data_mut(|data| data.insert_temp(season_id, picked));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
|
let Some(shown) = title.seasons.get(season) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("drawer-episodes")
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for episode in &shown.episodes {
|
||||||
|
let row = drawer_row(
|
||||||
|
ui,
|
||||||
|
&mut list.art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
is_playing(episode),
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
if is_playing(episode) {
|
||||||
|
let scrolled = ctx.data(|data| {
|
||||||
|
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
|
||||||
|
});
|
||||||
|
if scrolled.as_deref() != Some(target.link_id.as_str()) {
|
||||||
|
row.scroll_to_me(Some(egui::Align::Center));
|
||||||
|
ctx.data_mut(|data| {
|
||||||
|
data.insert_temp(
|
||||||
|
egui::Id::new(DRAWER_SCROLLED),
|
||||||
|
target.link_id.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode in the list: its still, which plays it, and what it is.
|
||||||
|
fn drawer_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut ui::Art<'_>,
|
||||||
|
title: &pstr_core::library::Title,
|
||||||
|
episode: &pstr_core::library::Episode,
|
||||||
|
playing: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) -> egui::Response {
|
||||||
|
let frame = egui::Frame::new()
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(6))
|
||||||
|
.fill(if playing {
|
||||||
|
theme::accent_dim()
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
});
|
||||||
|
let response = frame
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui::title::still(
|
||||||
|
ui,
|
||||||
|
art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
episode.is_watched(),
|
||||||
|
DRAWER_STILL,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = theme::space::XXS;
|
||||||
|
let name = art
|
||||||
|
.episode(&title.key, episode)
|
||||||
|
.and_then(|found| found.name.clone())
|
||||||
|
.unwrap_or_else(|| episode.detail().to_owned());
|
||||||
|
if let Some(numbering) = episode.numbering() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(numbering)
|
||||||
|
.monospace()
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
name,
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
if episode.is_watched() && !playing {
|
||||||
|
theme::muted()
|
||||||
|
} else {
|
||||||
|
theme::text()
|
||||||
|
},
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 2;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
ui.label(job);
|
||||||
|
let state = if playing {
|
||||||
|
Some("Playing".to_owned())
|
||||||
|
} else if episode.is_watched() {
|
||||||
|
Some("Watched".to_owned())
|
||||||
|
} else {
|
||||||
|
episode
|
||||||
|
.resume_at()
|
||||||
|
.map(|at| format!("Stopped at {}", ui::format_time(at)))
|
||||||
|
};
|
||||||
|
if let Some(state) = state {
|
||||||
|
ui.label(theme::Role::Caption.rich(state).color(if playing {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
/// The gradient behind the title strip, fading downwards out of the top edge.
|
/// The gradient behind the title strip, fading downwards out of the top edge.
|
||||||
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
||||||
// Squared, so the fade is dark where the text is and gone well before the
|
// Squared, so the fade is dark where the text is and gone well before the
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ pub fn show(
|
|||||||
ui.add_space(margin);
|
ui.add_space(margin);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.set_width(width);
|
ui.set_width(width);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
appearance_form(ui, prefs.appearance, actions);
|
appearance_form(ui, prefs.appearance, actions);
|
||||||
playback_form(ui, prefs, actions);
|
playback_form(ui, prefs, actions);
|
||||||
storage_form(ui, prefs, actions);
|
storage_form(ui, prefs, actions);
|
||||||
@@ -68,9 +68,9 @@ pub fn show(
|
|||||||
/// only changed a small square would be asking someone to imagine the result.
|
/// only changed a small square would be asking someone to imagine the result.
|
||||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||||
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
|
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
for flavor in Flavor::ALL {
|
for flavor in Flavor::ALL {
|
||||||
@@ -86,7 +86,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.spacing_mut().item_spacing.y = 0.0;
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
|
||||||
ui::widgets::settings_row(ui, "Accent", None, |ui| {
|
ui::widgets::settings_row(ui, "Accent", None, |ui| {
|
||||||
@@ -132,11 +132,11 @@ fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> eg
|
|||||||
..appearance
|
..appearance
|
||||||
});
|
});
|
||||||
let selected = appearance.flavor == flavor;
|
let selected = appearance.flavor == flavor;
|
||||||
let hover = ui
|
let hover =
|
||||||
.ctx()
|
ui.ctx()
|
||||||
.animate_bool_with_time(response.id, response.hovered(), 0.10);
|
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let radius = egui::CornerRadius::same(8);
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
|
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
|
||||||
|
|
||||||
painter.rect_filled(preview, radius, palette.background);
|
painter.rect_filled(preview, radius, palette.background);
|
||||||
@@ -155,11 +155,20 @@ fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> eg
|
|||||||
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
|
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] {
|
for x in [8.0, 49.0, 90.0] {
|
||||||
painter.rect_filled(tile(x), egui::CornerRadius::same(3), palette.card);
|
painter.rect_filled(
|
||||||
|
tile(x),
|
||||||
|
egui::CornerRadius::same(theme::radius::BAR),
|
||||||
|
palette.card,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let pill =
|
let pill =
|
||||||
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
|
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);
|
theme::swatch_fill(
|
||||||
|
painter,
|
||||||
|
pill,
|
||||||
|
egui::CornerRadius::same(theme::radius::BAR),
|
||||||
|
&palette,
|
||||||
|
);
|
||||||
painter.hline(
|
painter.hline(
|
||||||
(preview.left() + 8.0)..=(preview.left() + 60.0),
|
(preview.left() + 8.0)..=(preview.left() + 60.0),
|
||||||
preview.top() + 50.0,
|
preview.top() + 50.0,
|
||||||
@@ -354,7 +363,7 @@ fn metadata_form(
|
|||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
|
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.spacing_mut().item_spacing.y = 6.0;
|
ui.spacing_mut().item_spacing.y = 6.0;
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
||||||
@@ -362,7 +371,7 @@ fn metadata_form(
|
|||||||
in your library — not your files, and nothing about what you have watched — to \
|
in your library — not your files, and nothing about what you have watched — to \
|
||||||
the provider you choose, over HTTPS, each time a new one appears.",
|
the provider you choose, over HTTPS, each time a new one appears.",
|
||||||
));
|
));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut enabled = settings.enabled;
|
let mut enabled = settings.enabled;
|
||||||
@@ -372,7 +381,7 @@ fn metadata_form(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if settings.enabled {
|
if settings.enabled {
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(ui::muted("Provider"));
|
ui.label(ui::muted("Provider"));
|
||||||
for provider in ProviderId::ALL {
|
for provider in ProviderId::ALL {
|
||||||
if ui
|
if ui
|
||||||
@@ -387,7 +396,7 @@ fn metadata_form(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if settings.provider.needs_api_key() {
|
if settings.provider.needs_api_key() {
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
@@ -409,7 +418,7 @@ fn metadata_form(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui::accent_button(ui, "Match the library").clicked() {
|
if ui::accent_button(ui, "Match the library").clicked() {
|
||||||
actions.push(Action::MatchTitles { force: false });
|
actions.push(Action::MatchTitles { force: false });
|
||||||
@@ -423,7 +432,7 @@ fn metadata_form(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
||||||
already stored.",
|
already stored.",
|
||||||
@@ -433,6 +442,6 @@ fn metadata_form(
|
|||||||
if changed {
|
if changed {
|
||||||
actions.push(Action::SetMetadataConfig(settings.clone()));
|
actions.push(Action::SetMetadataConfig(settings.clone()));
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ pub fn show(
|
|||||||
share_row(ui, share, titles, actions);
|
share_row(ui, share, titles, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(22.0);
|
ui.add_space(theme::space::XXL);
|
||||||
add_form(ui, shares, form, actions);
|
add_form(ui, shares, form, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ pub fn show(
|
|||||||
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
@@ -76,7 +76,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
@@ -85,11 +85,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
"Paste the whole link, including everything after the # — that part is the key that \
|
"Paste the whole link, including everything after the # — that part is the key that \
|
||||||
decrypts it, and it is stored in your system keyring rather than on disk.",
|
decrypts it, and it is stored in your system keyring rather than on disk.",
|
||||||
));
|
));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::same(14))
|
.inner_margin(egui::Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
@@ -100,7 +100,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
.hint_text("Anime")
|
.hint_text("Anime")
|
||||||
.desired_width(f32::INFINITY),
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -134,11 +134,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
if let Some(problem) = &problem {
|
if let Some(problem) = &problem {
|
||||||
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
|
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
|
||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||||
if form.has_password {
|
if form.has_password {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut form.password)
|
egui::TextEdit::singleline(&mut form.password)
|
||||||
.password(true)
|
.password(true)
|
||||||
@@ -147,7 +147,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
let ready = !form.name.trim().is_empty()
|
let ready = !form.name.trim().is_empty()
|
||||||
&& !form.url.trim().is_empty()
|
&& !form.url.trim().is_empty()
|
||||||
&& problem.is_none()
|
&& problem.is_none()
|
||||||
@@ -156,7 +156,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
|
|
||||||
if let Some(error) = &form.error {
|
if let Some(error) = &form.error {
|
||||||
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
if ui
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
|||||||
&[
|
&[
|
||||||
("Ctrl F or /", "Search the library"),
|
("Ctrl F or /", "Search the library"),
|
||||||
("F5 or Ctrl R", "Crawl every share again"),
|
("F5 or Ctrl R", "Crawl every share again"),
|
||||||
("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"),
|
(
|
||||||
|
"Ctrl 1 – 5",
|
||||||
|
"Library, History, Shares, Downloads, Settings",
|
||||||
|
),
|
||||||
("Ctrl ,", "Settings"),
|
("Ctrl ,", "Settings"),
|
||||||
("Ctrl V", "Paste a share link to add it"),
|
("Ctrl V", "Paste a share link to add it"),
|
||||||
("Tab or arrows", "Move between titles and episodes"),
|
("Tab or arrows", "Move between titles and episodes"),
|
||||||
@@ -33,6 +36,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("N / P", "Next and previous episode"),
|
("N / P", "Next and previous episode"),
|
||||||
("[ / ]", "Slower and faster"),
|
("[ / ]", "Slower and faster"),
|
||||||
("S", "Skip the opening or the credits"),
|
("S", "Skip the opening or the credits"),
|
||||||
|
("E", "The list of episodes"),
|
||||||
("F or double-click", "Fullscreen"),
|
("F or double-click", "Fullscreen"),
|
||||||
("Esc", "Leave fullscreen, then the player"),
|
("Esc", "Leave fullscreen, then the player"),
|
||||||
],
|
],
|
||||||
@@ -47,7 +51,7 @@ pub fn show(ctx: &egui::Context) -> bool {
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::surface())
|
.fill(theme::surface())
|
||||||
.inner_margin(egui::Margin::same(20))
|
.inner_margin(egui::Margin::same(20))
|
||||||
.corner_radius(egui::CornerRadius::same(12)),
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
|
||||||
)
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(460.0);
|
ui.set_width(460.0);
|
||||||
@@ -60,9 +64,9 @@ pub fn show(ctx: &egui::Context) -> bool {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
for (heading, keys) in GROUPS {
|
for (heading, keys) in GROUPS {
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(ui::muted(heading.to_uppercase()));
|
ui.label(ui::muted(heading.to_uppercase()));
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
egui::Grid::new(*heading)
|
egui::Grid::new(*heading)
|
||||||
.num_columns(2)
|
.num_columns(2)
|
||||||
.spacing([18.0, 8.0])
|
.spacing([18.0, 8.0])
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ pub fn show(
|
|||||||
if back_link(ui).clicked() {
|
if back_link(ui).clicked() {
|
||||||
actions.push(Action::Goto(Page::Library));
|
actions.push(Action::Goto(Page::Library));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
header(ui, art, title, &offline, actions);
|
header(ui, art, title, &offline, actions);
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
|
|
||||||
// One season at a time, picked from a row of pills, rather than
|
// One season at a time, picked from a row of pills, rather than
|
||||||
// every season stacked as a collapsing header: a four-season show
|
// every season stacked as a collapsing header: a four-season show
|
||||||
@@ -78,7 +78,7 @@ pub fn show(
|
|||||||
picked = index;
|
picked = index;
|
||||||
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
}
|
}
|
||||||
ui.label(ui::muted(ui::library::plural(
|
ui.label(ui::muted(ui::library::plural(
|
||||||
season.episodes.len(),
|
season.episodes.len(),
|
||||||
@@ -88,7 +88,7 @@ pub fn show(
|
|||||||
season_download(ui, title, season, &offline, actions);
|
season_download(ui, title, season, &offline, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
let Some(season) = title.seasons.get(picked) else {
|
let Some(season) = title.seasons.get(picked) else {
|
||||||
return;
|
return;
|
||||||
@@ -96,7 +96,7 @@ pub fn show(
|
|||||||
for episode in &season.episodes {
|
for episode in &season.episodes {
|
||||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ pub fn show(
|
|||||||
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
|
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
|
||||||
let size = egui::vec2(180.0, 270.0);
|
let size = egui::vec2(180.0, 270.0);
|
||||||
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
let radius = egui::CornerRadius::same(10);
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
ui.painter()
|
ui.painter()
|
||||||
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
|
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
|
||||||
ui.painter().add(
|
ui.painter().add(
|
||||||
@@ -128,7 +128,7 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
|
|||||||
/// "← Library", as a link rather than a button: it is navigation, and a
|
/// "← Library", as a link rather than a button: it is navigation, and a
|
||||||
/// filled button beside the page's real actions competed with them.
|
/// filled button beside the page's real actions competed with them.
|
||||||
fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||||
let text = theme::Role::Label.rich("← Library");
|
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
|
||||||
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
ui.painter().hline(
|
ui.painter().hline(
|
||||||
@@ -201,7 +201,7 @@ fn header(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
||||||
|
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
match &picture {
|
match &picture {
|
||||||
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
|
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
|
||||||
@@ -224,7 +224,7 @@ fn header(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(22.0);
|
ui.add_space(theme::space::XXL);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
// Text in a column a person can read, with the picture to the
|
// Text in a column a person can read, with the picture to the
|
||||||
// right of it rather than under it.
|
// right of it rather than under it.
|
||||||
@@ -242,9 +242,9 @@ fn header(
|
|||||||
if by_hand { " (chosen by hand)" } else { "" }
|
if by_hand { " (chosen by hand)" } else { "" }
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(episode) = title.next_up() {
|
if let Some(episode) = title.next_up() {
|
||||||
@@ -342,14 +342,14 @@ fn header(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Some(found) = &found {
|
if let Some(found) = &found {
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
description(ui, found);
|
description(ui, found);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some((texture, index)) = backdrop {
|
if let Some((texture, index)) = backdrop {
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
band(ui, &texture, index);
|
band(ui, &texture, index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +456,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
|||||||
ui::widgets::chip(ui, genre);
|
ui::widgets::chip(ui, genre);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
}
|
}
|
||||||
if let Some(overview) = &found.overview {
|
if let Some(overview) = &found.overview {
|
||||||
// Four lines, then "More". A provider's synopsis runs to three
|
// Four lines, then "More". A provider's synopsis runs to three
|
||||||
@@ -496,7 +496,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(url) = &found.url {
|
if let Some(url) = &found.url {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.hyperlink_to(
|
ui.hyperlink_to(
|
||||||
ui::muted(format!("More on {}", found.provider.label())),
|
ui::muted(format!("More on {}", found.provider.label())),
|
||||||
url,
|
url,
|
||||||
@@ -532,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
|
|
||||||
if let Some(found) = found {
|
if let Some(found) = found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("★ {rating:.1}"));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
// Only when it disagrees with what is on disk: "25 episodes · 25
|
// Only when it disagrees with what is on disk: "25 episodes · 25
|
||||||
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
|
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
|
||||||
@@ -573,15 +573,15 @@ fn episode_row(
|
|||||||
let row_id = ui.id().with(("episode", &episode.node.link_id));
|
let row_id = ui.id().with(("episode", &episode.node.link_id));
|
||||||
|
|
||||||
let frame = egui::Frame::new()
|
let frame = egui::Frame::new()
|
||||||
.corner_radius(egui::CornerRadius::same(10))
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
|
||||||
.inner_margin(egui::Margin::same(8));
|
.inner_margin(egui::Margin::same(8));
|
||||||
let mut prepared = frame.begin(ui);
|
let mut prepared = frame.begin(ui);
|
||||||
{
|
{
|
||||||
let ui = &mut prepared.content_ui;
|
let ui = &mut prepared.content_ui;
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
still(ui, art, title, episode, watched, actions);
|
still(ui, art, title, episode, watched, STILL, actions);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
let controls = 96.0;
|
let controls = 96.0;
|
||||||
let text_width = (ui.available_width() - controls).max(120.0);
|
let text_width = (ui.available_width() - controls).max(120.0);
|
||||||
@@ -643,8 +643,13 @@ fn episode_row(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
// A fixed height, the still's. `with_layout` would hand the
|
||||||
ui.set_min_height(STILL.y);
|
// buttons all the height left in the scroll area's viewport and
|
||||||
|
// centre them in it, so whichever row sat highest on screen
|
||||||
|
// stretched down to the bottom of the window.
|
||||||
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
if ui::widgets::watched_mark(ui, watched)
|
if ui::widgets::watched_mark(ui, watched)
|
||||||
.on_hover_text(if watched {
|
.on_hover_text(if watched {
|
||||||
"Mark unwatched"
|
"Mark unwatched"
|
||||||
@@ -660,7 +665,7 @@ fn episode_row(
|
|||||||
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
download(ui, title, episode, offline, actions);
|
download(ui, title, episode, offline, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -671,11 +676,11 @@ fn episode_row(
|
|||||||
let lit = ui.ctx().animate_bool_with_time(
|
let lit = ui.ctx().animate_bool_with_time(
|
||||||
row_id,
|
row_id,
|
||||||
response.hovered() || response.contains_pointer(),
|
response.hovered() || response.contains_pointer(),
|
||||||
0.10,
|
theme::motion::HOVER,
|
||||||
);
|
);
|
||||||
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
||||||
prepared.paint(ui);
|
prepared.paint(ui);
|
||||||
ui.add_space(2.0);
|
ui.add_space(theme::space::XXS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grey line under an episode's name: when it aired, how big it is, and
|
/// The grey line under an episode's name: when it aired, how big it is, and
|
||||||
@@ -711,19 +716,20 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
|||||||
/// An explicit target rather than a clickable row: the row also carries the
|
/// 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
|
/// 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.
|
/// those must never be one that starts a 1.4 GiB stream instead.
|
||||||
fn still(
|
pub(crate) fn still(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
title: &Title,
|
title: &Title,
|
||||||
episode: &Episode,
|
episode: &Episode,
|
||||||
watched: bool,
|
watched: bool,
|
||||||
|
size: egui::Vec2,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let (rect, response) = ui.allocate_exact_size(STILL, egui::Sense::click());
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let radius = egui::CornerRadius::same(8);
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.rect_filled(rect, radius, theme::card_hover());
|
painter.rect_filled(rect, radius, theme::card_hover());
|
||||||
// Asked for only once the row is on screen: a season of twenty-five is
|
// Asked for only once the row is on screen: a season of twenty-five is
|
||||||
@@ -746,7 +752,7 @@ fn still(
|
|||||||
let hover = ui.ctx().animate_bool_with_time(
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
response.id,
|
response.id,
|
||||||
response.hovered() || response.has_focus(),
|
response.hovered() || response.has_focus(),
|
||||||
0.12,
|
theme::motion::HOVER,
|
||||||
);
|
);
|
||||||
if hover > 0.0 {
|
if hover > 0.0 {
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ERROR_SECONDS: f64 = 9.0;
|
|||||||
/// How many fit at once. A burst beyond that drops the oldest.
|
/// How many fit at once. A burst beyond that drops the oldest.
|
||||||
const MOST: usize = 4;
|
const MOST: usize = 4;
|
||||||
/// How long one takes to arrive and to leave.
|
/// How long one takes to arrive and to leave.
|
||||||
const FADE: f64 = 0.18;
|
const FADE: f64 = theme::motion::FADE as f64;
|
||||||
const WIDTH: f32 = 340.0;
|
const WIDTH: f32 = 340.0;
|
||||||
|
|
||||||
pub struct Toast {
|
pub struct Toast {
|
||||||
@@ -98,7 +98,7 @@ impl Toasts {
|
|||||||
let age = now - toast.at;
|
let age = now - toast.at;
|
||||||
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
|
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
|
||||||
let leaving = ((life - 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 opacity = theme::motion::ease_out(arriving.min(leaving));
|
||||||
|
|
||||||
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
|
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
|
||||||
.order(egui::Order::Foreground)
|
.order(egui::Order::Foreground)
|
||||||
@@ -150,10 +150,6 @@ fn lifetime(error: bool) -> f64 {
|
|||||||
if error { ERROR_SECONDS } else { SECONDS }
|
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.
|
/// Which of a toast's buttons was pressed.
|
||||||
enum Pressed {
|
enum Pressed {
|
||||||
Nothing,
|
Nothing,
|
||||||
@@ -172,7 +168,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::surface())
|
.fill(theme::surface())
|
||||||
.stroke(Stroke::new(1.0, theme::card_hover()))
|
.stroke(Stroke::new(1.0, theme::card_hover()))
|
||||||
.corner_radius(CornerRadius::same(10))
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
.inner_margin(egui::Margin {
|
.inner_margin(egui::Margin {
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 10,
|
right: 10,
|
||||||
@@ -211,7 +207,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
|||||||
pressed = Pressed::Close;
|
pressed = Pressed::Close;
|
||||||
}
|
}
|
||||||
if let Some((label, _)) = &toast.action {
|
if let Some((label, _)) = &toast.action {
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(
|
egui::Button::new(
|
||||||
@@ -240,7 +236,8 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
|||||||
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
|
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
|
||||||
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
|
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
|
||||||
);
|
);
|
||||||
ui.painter().rect_filled(strip, CornerRadius::same(2), edge);
|
ui.painter()
|
||||||
|
.rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge);
|
||||||
});
|
});
|
||||||
pressed
|
pressed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ pub fn full(
|
|||||||
) {
|
) {
|
||||||
seek_bar(ui, playback, actions, 6.0);
|
seek_bar(ui, playback, actions, 6.0);
|
||||||
times(ui, playback, true);
|
times(ui, playback, true);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.set_min_height(ROW_HEIGHT_FULL);
|
ui.set_min_height(ROW_HEIGHT_FULL);
|
||||||
@@ -65,7 +65,7 @@ pub fn full(
|
|||||||
// hand reaches for it.
|
// hand reaches for it.
|
||||||
step_button(
|
step_button(
|
||||||
ui,
|
ui,
|
||||||
"⏮",
|
egui_phosphor::regular::SKIP_BACK,
|
||||||
"Previous (P)",
|
"Previous (P)",
|
||||||
neighbours.previous,
|
neighbours.previous,
|
||||||
actions,
|
actions,
|
||||||
@@ -76,11 +76,18 @@ pub fn full(
|
|||||||
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
|
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
|
||||||
play_pause(ui, playback, actions, 44.0);
|
play_pause(ui, playback, actions, 44.0);
|
||||||
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
|
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
|
||||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
step_button(
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
ui,
|
||||||
});
|
egui_phosphor::regular::SKIP_FORWARD,
|
||||||
|
"Next (N)",
|
||||||
|
neighbours.next,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
if playback.seeking || playback.buffering || !playback.loaded {
|
if playback.seeking || playback.buffering || !playback.loaded {
|
||||||
ui.add(egui::Spinner::new().size(16.0));
|
ui.add(egui::Spinner::new().size(16.0));
|
||||||
ui.label(ui::muted(if playback.loaded && playback.seeking {
|
ui.label(ui::muted(if playback.loaded && playback.seeking {
|
||||||
@@ -93,11 +100,11 @@ pub fn full(
|
|||||||
// Right cluster: everything that is a choice rather than a move.
|
// Right cluster: everything that is a choice rather than a move.
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
track_menus(ui, playback, actions);
|
track_menus(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
speed_menu(ui, playback, actions);
|
speed_menu(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
chapter_menu(ui, playback, actions);
|
chapter_menu(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
volume(ui, playback, actions);
|
volume(ui, playback, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -123,7 +130,7 @@ pub fn mini(
|
|||||||
Some(texture) => {
|
Some(texture) => {
|
||||||
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
|
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let radius = CornerRadius::same(6);
|
let radius = CornerRadius::same(theme::radius::SM);
|
||||||
painter.rect_filled(rect, radius, Color32::BLACK);
|
painter.rect_filled(rect, radius, Color32::BLACK);
|
||||||
painter.add(
|
painter.add(
|
||||||
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
|
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
|
||||||
@@ -134,7 +141,7 @@ pub fn mini(
|
|||||||
let hover = ui.ctx().animate_bool_with_time(
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
response.id.with("hover"),
|
response.id.with("hover"),
|
||||||
response.hovered(),
|
response.hovered(),
|
||||||
0.12,
|
theme::motion::HOVER,
|
||||||
);
|
);
|
||||||
if hover > 0.0 {
|
if hover > 0.0 {
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
@@ -165,7 +172,7 @@ pub fn mini(
|
|||||||
.add(
|
.add(
|
||||||
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
||||||
.fill(theme::card_hover())
|
.fill(theme::card_hover())
|
||||||
.corner_radius(CornerRadius::same(8)),
|
.corner_radius(CornerRadius::same(theme::radius::MD)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Show the picture again")
|
.on_hover_text("Show the picture again")
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -174,7 +181,7 @@ pub fn mini(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.set_min_height(ROW_HEIGHT_MINI);
|
ui.set_min_height(ROW_HEIGHT_MINI);
|
||||||
@@ -201,14 +208,21 @@ pub fn mini(
|
|||||||
if ui.button("Stop").clicked() {
|
if ui.button("Stop").clicked() {
|
||||||
actions.push(Action::Player(Command::Stop));
|
actions.push(Action::Player(Command::Stop));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
step_button(
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
ui,
|
||||||
});
|
egui_phosphor::regular::SKIP_FORWARD,
|
||||||
|
"Next (N)",
|
||||||
|
neighbours.next,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
},
|
||||||
|
);
|
||||||
play_pause(ui, playback, actions, 34.0);
|
play_pause(ui, playback, actions, 34.0);
|
||||||
step_button(
|
step_button(
|
||||||
ui,
|
ui,
|
||||||
"⏮",
|
egui_phosphor::regular::SKIP_BACK,
|
||||||
"Previous (P)",
|
"Previous (P)",
|
||||||
neighbours.previous,
|
neighbours.previous,
|
||||||
actions,
|
actions,
|
||||||
@@ -217,18 +231,18 @@ pub fn mini(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
track_menus(ui, playback, actions);
|
track_menus(ui, playback, actions);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
volume(ui, playback, actions);
|
volume(ui, playback, actions);
|
||||||
|
|
||||||
if playback.seeking || playback.buffering || !playback.loaded {
|
if playback.seeking || playback.buffering || !playback.loaded {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
seek_bar(ui, playback, actions, 5.0);
|
seek_bar(ui, playback, actions, 5.0);
|
||||||
times(ui, playback, false);
|
times(ui, playback, false);
|
||||||
});
|
});
|
||||||
@@ -414,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let icon = if playback.muted || playback.volume <= 0.0 {
|
let icon = if playback.muted || playback.volume <= 0.0 {
|
||||||
"🔇"
|
egui_phosphor::regular::SPEAKER_SLASH
|
||||||
|
} else if playback.volume < 50.0 {
|
||||||
|
egui_phosphor::regular::SPEAKER_LOW
|
||||||
} else {
|
} else {
|
||||||
"🔊"
|
egui_phosphor::regular::SPEAKER_HIGH
|
||||||
};
|
};
|
||||||
if ui
|
if ui
|
||||||
.button(icon)
|
.button(theme::Role::Subhead.rich(icon))
|
||||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -553,11 +569,11 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
|
|
||||||
let active = response.hovered() || response.dragged();
|
let active = response.hovered() || response.dragged();
|
||||||
// Grows rather than jumps when the pointer arrives.
|
// Grows rather than jumps when the pointer arrives.
|
||||||
let grow = ui
|
let grow =
|
||||||
.ctx()
|
ui.ctx()
|
||||||
.animate_bool_with_time(response.id.with("grow"), active, 0.12);
|
.animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER);
|
||||||
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
|
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
|
||||||
let radius = CornerRadius::same(3);
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
// From the palette rather than a fixed grey: the same bar sits on the
|
// 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.
|
// video's dark scrim and on a light theme's transport strip.
|
||||||
@@ -676,6 +692,10 @@ fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
|
|||||||
let screen = ui.ctx().content_rect();
|
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 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);
|
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.rect_filled(
|
||||||
|
frame,
|
||||||
|
CornerRadius::same(theme::radius::SM),
|
||||||
|
Color32::from_black_alpha(215),
|
||||||
|
);
|
||||||
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
|
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
|
||||||
}
|
}
|
||||||
@@ -40,8 +40,12 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
|||||||
|
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let ctx = ui.ctx();
|
let ctx = ui.ctx();
|
||||||
let t = ctx.animate_bool_with_time(response.id, *on, 0.14);
|
let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE);
|
||||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
let track = egui::Rect::from_min_size(
|
let track = egui::Rect::from_min_size(
|
||||||
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
|
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
|
||||||
TRACK,
|
TRACK,
|
||||||
@@ -96,8 +100,12 @@ pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
|||||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let ctx = ui.ctx();
|
let ctx = ui.ctx();
|
||||||
let t = ctx.animate_bool_with_time(response.id, watched, 0.14);
|
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let center = rect.center();
|
let center = rect.center();
|
||||||
let radius = SIZE / 2.0 - 1.0;
|
let radius = SIZE / 2.0 - 1.0;
|
||||||
@@ -147,7 +155,7 @@ pub fn settings_group<R>(
|
|||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
add: impl FnOnce(&mut egui::Ui) -> R,
|
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
) -> R {
|
) -> R {
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Subhead
|
theme::Role::Subhead
|
||||||
.rich(heading)
|
.rich(heading)
|
||||||
@@ -155,13 +163,13 @@ pub fn settings_group<R>(
|
|||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
if let Some(description) = description {
|
if let Some(description) = description {
|
||||||
ui.add_space(2.0);
|
ui.add_space(theme::space::XXS);
|
||||||
ui.label(crate::ui::muted(description));
|
ui.label(crate::ui::muted(description));
|
||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
let inner = egui::Frame::new()
|
let inner = egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(CornerRadius::same(10))
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
.inner_margin(egui::Margin::symmetric(16, 6))
|
.inner_margin(egui::Margin::symmetric(16, 6))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
@@ -169,7 +177,7 @@ pub fn settings_group<R>(
|
|||||||
add(ui)
|
add(ui)
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
ui.add_space(26.0);
|
ui.add_space(theme::space::XXL);
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +200,7 @@ pub fn settings_row<R>(
|
|||||||
Stroke::new(1.0, theme::card_hover()),
|
Stroke::new(1.0, theme::card_hover()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
let inner = ui
|
let inner = ui
|
||||||
.horizontal(|ui| {
|
.horizontal(|ui| {
|
||||||
ui.set_min_height(28.0);
|
ui.set_min_height(28.0);
|
||||||
@@ -209,7 +217,7 @@ pub fn settings_row<R>(
|
|||||||
.inner
|
.inner
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +232,7 @@ pub fn segmented<T: Copy + PartialEq>(
|
|||||||
let mut picked = None;
|
let mut picked = None;
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card_hover())
|
.fill(theme::card_hover())
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::same(3))
|
.inner_margin(egui::Margin::same(3))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 2.0;
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
@@ -237,7 +245,7 @@ pub fn segmented<T: Copy + PartialEq>(
|
|||||||
theme::text()
|
theme::text()
|
||||||
});
|
});
|
||||||
let button = egui::Button::new(text)
|
let button = egui::Button::new(text)
|
||||||
.corner_radius(CornerRadius::same(6))
|
.corner_radius(CornerRadius::same(theme::radius::SM))
|
||||||
.fill(if selected {
|
.fill(if selected {
|
||||||
theme::accent()
|
theme::accent()
|
||||||
} else {
|
} else {
|
||||||
@@ -273,9 +281,9 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
|
|||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
let hover = ui
|
let hover =
|
||||||
.ctx()
|
ui.ctx()
|
||||||
.animate_bool_with_time(response.id, response.hovered(), 0.10);
|
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let c = rect.center();
|
let c = rect.center();
|
||||||
if hover > 0.0 {
|
if hover > 0.0 {
|
||||||
@@ -352,7 +360,7 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
|
|||||||
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card_hover().gamma_multiply(0.8))
|
.fill(theme::card_hover().gamma_multiply(0.8))
|
||||||
.corner_radius(CornerRadius::same(10))
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
.inner_margin(egui::Margin::symmetric(9, 3))
|
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||||
|
|||||||
@@ -369,6 +369,26 @@ impl Library {
|
|||||||
titles
|
titles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every episode that has been started or finished, most recently
|
||||||
|
/// touched first. The history page.
|
||||||
|
///
|
||||||
|
/// An episode marked unwatched keeps a row with position zero, which is
|
||||||
|
/// "never played" for every purpose here, so it is left out.
|
||||||
|
pub fn history(&self) -> Vec<(&Title, &Episode)> {
|
||||||
|
let mut played: Vec<(&Title, &Episode)> = self
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.flat_map(|title| title.episodes().map(move |episode| (title, episode)))
|
||||||
|
.filter(|(_, episode)| {
|
||||||
|
episode
|
||||||
|
.watch
|
||||||
|
.is_some_and(|watch| watch.watched || watch.position_secs > 0.0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played()));
|
||||||
|
played
|
||||||
|
}
|
||||||
|
|
||||||
/// Substring match over the display name and every filename under it, so
|
/// Substring match over the display name and every filename under it, so
|
||||||
/// searching for a release group or an episode name finds its title.
|
/// searching for a release group or an episode name finds its title.
|
||||||
pub fn search(&self, query: &str) -> Vec<&Title> {
|
pub fn search(&self, query: &str) -> Vec<&Title> {
|
||||||
@@ -517,6 +537,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_is_every_played_episode_newest_first_across_titles() {
|
||||||
|
let watch_state = HashMap::from([
|
||||||
|
(
|
||||||
|
("s".to_string(), "a".to_string()),
|
||||||
|
watch(600.0, 1400.0, false, 10),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("s".to_string(), "c".to_string()),
|
||||||
|
watch(1400.0, 1400.0, true, 30),
|
||||||
|
),
|
||||||
|
// Marked unwatched again: a row at zero, which is not a play.
|
||||||
|
(
|
||||||
|
("s".to_string(), "b".to_string()),
|
||||||
|
watch(0.0, 1400.0, false, 40),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let library = Library::build(
|
||||||
|
vec![
|
||||||
|
node("s", "a", "Show - S01E01", Some(1), Some(1)),
|
||||||
|
node("s", "b", "Show - S01E02", Some(1), Some(2)),
|
||||||
|
node("s", "c", "Film", None, None),
|
||||||
|
node("s", "d", "Other - S01E01", Some(1), Some(1)),
|
||||||
|
],
|
||||||
|
&watch_state,
|
||||||
|
);
|
||||||
|
|
||||||
|
let order: Vec<&str> = library
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.map(|(_, episode)| episode.node.link_id.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(order, ["c", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_episodes_into_seasons() {
|
fn groups_episodes_into_seasons() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
|
|||||||
@@ -10,15 +10,23 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
|
|
||||||
use crate::block::{BlockMap, BlockSource, SharedBlocks};
|
use crate::block::{BlockMap, BlockSource, SharedBlocks};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::source::RevisionOpener;
|
use crate::source::RevisionOpener;
|
||||||
|
|
||||||
|
/// How many times one block is fetched before its failure reaches the reader.
|
||||||
|
const BLOCK_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// The pause before a block's second fetch, doubled before each one after.
|
||||||
|
const BLOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// One open revision on a public link.
|
/// One open revision on a public link.
|
||||||
pub struct RevisionBlocks {
|
pub struct RevisionBlocks {
|
||||||
reader: proton_drive_rs::RevisionReader,
|
reader: proton_drive_rs::RevisionReader,
|
||||||
@@ -50,7 +58,7 @@ impl BlockSource for RevisionBlocks {
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let block = self.reader.read_at(start, size).await?;
|
let block = fetch_retrying(BLOCK_RETRY_DELAY, || self.reader.read_at(start, size)).await?;
|
||||||
// A short block here is not a tail — the map says how long it is. It
|
// A short block here is not a tail — the map says how long it is. It
|
||||||
// means the revision changed underneath us or the block table lied, and
|
// means the revision changed underneath us or the block table lied, and
|
||||||
// serving it would silently shift every later byte.
|
// serving it would silently shift every later byte.
|
||||||
@@ -64,6 +72,38 @@ impl BlockSource for RevisionBlocks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a block, again if the failure is one a replay could get past.
|
||||||
|
///
|
||||||
|
/// The SDK retries a request that fails before its response starts, but not a
|
||||||
|
/// block body cut off part way through: that error reaches us as it happened,
|
||||||
|
/// and without this it stops playback or fails a download. Its keep-alive pings
|
||||||
|
/// turn a silently dead connection into exactly that error, seconds in rather
|
||||||
|
/// than at the storage timeout, and the next attempt goes out on a new
|
||||||
|
/// connection. [`ProtonError::is_retriable`] decides what counts, so a
|
||||||
|
/// permanent refusal still fails on the first attempt.
|
||||||
|
async fn fetch_retrying<T, F, Fut>(
|
||||||
|
first_delay: Duration,
|
||||||
|
mut fetch: F,
|
||||||
|
) -> std::result::Result<T, ProtonError>
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: Future<Output = std::result::Result<T, ProtonError>>,
|
||||||
|
{
|
||||||
|
let mut delay = first_delay;
|
||||||
|
let mut attempt = 1;
|
||||||
|
loop {
|
||||||
|
match fetch().await {
|
||||||
|
Err(error) if attempt < BLOCK_ATTEMPTS && error.is_retriable() => {
|
||||||
|
tracing::debug!(attempt, %error, "block fetch failed; fetching again");
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
delay *= 2;
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
result => return result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
|
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
|
||||||
/// can seek normally without a second playback path.
|
/// can seek normally without a second playback path.
|
||||||
pub struct FileBlocks {
|
pub struct FileBlocks {
|
||||||
@@ -136,8 +176,60 @@ impl RevisionOpener for LibraryOpener {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
use proton_sdk::api::ResponseCode;
|
||||||
|
use proton_sdk::error::ProtonApiError;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn api_error(http_status: u16) -> ProtonError {
|
||||||
|
ProtonError::Api(ProtonApiError {
|
||||||
|
code: ResponseCode::Unknown,
|
||||||
|
http_status,
|
||||||
|
message: String::new(),
|
||||||
|
details: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run [`fetch_retrying`] over a fetch that fails with `failures` in turn and
|
||||||
|
/// then succeeds, returning the result and how many fetches it took.
|
||||||
|
async fn fetch_through(
|
||||||
|
failures: Vec<ProtonError>,
|
||||||
|
) -> (std::result::Result<u8, ProtonError>, u32) {
|
||||||
|
let calls = AtomicU32::new(0);
|
||||||
|
let failures = std::sync::Mutex::new(failures.into_iter());
|
||||||
|
let result = fetch_retrying(Duration::ZERO, || {
|
||||||
|
calls.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let next = failures.lock().unwrap().next();
|
||||||
|
async move { next.map_or(Ok(7), Err) }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
(result, calls.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_block_that_fails_retriably_is_fetched_again() {
|
||||||
|
let (result, calls) = fetch_through(vec![api_error(503)]).await;
|
||||||
|
assert_eq!(result.expect("second fetch succeeds"), 7);
|
||||||
|
assert_eq!(calls, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_permanent_refusal_is_not_fetched_again() {
|
||||||
|
let (result, calls) = fetch_through(vec![api_error(403)]).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(calls, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_block_that_keeps_failing_returns_its_last_error() {
|
||||||
|
let (result, calls) =
|
||||||
|
fetch_through((0..BLOCK_ATTEMPTS + 1).map(|_| api_error(503)).collect()).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(calls, BLOCK_ATTEMPTS);
|
||||||
|
}
|
||||||
|
|
||||||
/// The player hands its stream to mpv's demuxer thread, so everything from
|
/// The player hands its stream to mpv's demuxer thread, so everything from
|
||||||
/// the opener down has to cross threads. A compile-time guard, because the
|
/// the opener down has to cross threads. A compile-time guard, because the
|
||||||
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in
|
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in
|
||||||
|
|||||||
@@ -124,3 +124,5 @@ an edge case.
|
|||||||
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; 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 |
|
| 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 |
|
| Window size | config dir, `window.json` | Defaults if missing or implausible |
|
||||||
|
| Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing |
|
||||||
|
| Notification app id (Windows) | `HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream`, icon in cache dir | Yes — written again the first time a session notifies |
|
||||||
+31
-10
@@ -12,10 +12,11 @@ is the contract, and each side's copy is asserted against it by a test.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Colour roles | `pstr_core::appearance::Palette` | `ui/theme/Theme.kt` `schemeOf` | `SchemeRolesTest` |
|
| Colour roles | `pstr_core::appearance::Palette` | `ui/theme/Theme.kt` `schemeOf` | `SchemeRolesTest` |
|
||||||
| Type ramp | `pstr_app::theme::Role` | `ui/theme/Type.kt` | `TypeRampTest` |
|
| Type ramp | `pstr_app::theme::Role` | `ui/theme/Type.kt` | `TypeRampTest` |
|
||||||
| Corner radius | `pstr-app` `ui/mod.rs` | `ui/theme/Shape.kt` | `TypeRampTest` |
|
| Corner radius | `pstr_app::theme::radius` | `ui/theme/Shape.kt` | `TypeRampTest` |
|
||||||
| Gradient rule | `Palette::resolve` | — (derived, see below) | `appearance.rs` tests |
|
| Gradient rule | `Palette::resolve` | — (derived, see below) | `appearance.rs` tests |
|
||||||
| Depth | `pstr_app::theme::{tile_shadow, bar_shadow}` | Material elevation | — |
|
| Depth | `pstr_app::theme::{tile_shadow, bar_shadow}` | Material elevation | — |
|
||||||
| Motion | `Context::animate_*` | Compose defaults | — |
|
| Spacing | `pstr_app::theme::space` | Compose `dp` literals | — |
|
||||||
|
| Motion | `pstr_app::theme::motion` | Compose defaults | — |
|
||||||
|
|
||||||
## Type
|
## Type
|
||||||
|
|
||||||
@@ -35,8 +36,16 @@ screen and not for anything in a library grid).
|
|||||||
| Caption | 12 | context rather than content: counts, sizes, errors under a field |
|
| Caption | 12 | context rather than content: counts, sizes, errors under a field |
|
||||||
| Micro | 11 | the smallest thing that stays legible — a badge over artwork |
|
| Micro | 11 | the smallest thing that stays legible — a badge over artwork |
|
||||||
|
|
||||||
Weight and leading are each side's own: egui has one weight, and Compose derives
|
Weight and leading are each side's own. The desktop bundles Inter
|
||||||
line height as 1.35 × size rather than naming it nine more times.
|
(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in
|
||||||
|
its semibold cut, which egui sees as a family of its own (`theme::semibold`);
|
||||||
|
everything below Section is the regular cut. Compose derives line height as
|
||||||
|
1.35 × size rather than naming it nine more times.
|
||||||
|
|
||||||
|
Icons on the desktop come from the Phosphor icon font (`egui-phosphor`,
|
||||||
|
regular weight), installed right behind Inter so an icon can sit inside an
|
||||||
|
ordinary label. Do not use Unicode symbols such as ⏭ or 🔊 as icons: they come
|
||||||
|
out of whichever fallback font has them, at that font's size and baseline.
|
||||||
|
|
||||||
Both clients point their framework's *default* styles at the ramp — egui's five
|
Both clients point their framework's *default* styles at the ramp — egui's five
|
||||||
`TextStyle`s, Compose's fifteen — so a stock widget nobody restyled still lands
|
`TextStyle`s, Compose's fifteen — so a stock widget nobody restyled still lands
|
||||||
@@ -46,9 +55,19 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
|
|||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
**Radius 8** for everything either client paints: cards, buttons, the tab pill,
|
**Radius 8** (`radius::MD`) for everything either client paints: cards,
|
||||||
progress bars, dialogs. **4** for a badge over artwork. **16** for an Android
|
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
|
||||||
bottom sheet, which has no desktop counterpart.
|
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
|
||||||
|
small control inside something else, and **12** (`LG`) for what floats — row
|
||||||
|
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has
|
||||||
|
no desktop counterpart.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
|
||||||
|
24 (`XXS` to `XXL`). Use a step of the scale for a gap, not a literal. Sizes of
|
||||||
|
things — a still, a button, a column — are not spacing and stay where they are
|
||||||
|
defined.
|
||||||
|
|
||||||
Two traps, both Android's:
|
Two traps, both Android's:
|
||||||
|
|
||||||
@@ -96,9 +115,11 @@ page is a smudge on a near-white one. Android gets Material's elevation, which
|
|||||||
already adapts. Neither uses a tint to say "raised".
|
already adapts. Neither uses a tint to say "raised".
|
||||||
|
|
||||||
Transitions on the desktop go through `Context::animate_*`: wall-clock driven,
|
Transitions on the desktop go through `Context::animate_*`: wall-clock driven,
|
||||||
frame-rate independent, no state held by the caller. Durations in use are 0.05 s
|
frame-rate independent, no state held by the caller. Durations come from
|
||||||
for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.22 s
|
`theme::motion`: `PRESS` 0.05 s, `HOVER` 0.12 s, `STATE` 0.15 s (a switch, the
|
||||||
for artwork arriving. Compose's defaults are close enough that nothing here
|
tab pill's travel, a page change), `FADE` 0.18 s (player chrome, toasts) and
|
||||||
|
`ARRIVE` 0.24 s (artwork over its placeholder). A hand-driven transition eases
|
||||||
|
with `motion::ease_out`. Compose's defaults are close enough that nothing here
|
||||||
overrides them.
|
overrides them.
|
||||||
|
|
||||||
## Desktop widgets
|
## Desktop widgets
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// The app's one Rust engine and the screen state read from it.
|
||||||
|
///
|
||||||
|
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only
|
||||||
|
/// asks it questions off the main thread and keeps the answers for SwiftUI.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class AppModel {
|
||||||
|
let engine: AndroidEngine?
|
||||||
|
private(set) var startupError: String?
|
||||||
|
|
||||||
|
var titles: [TitleRecord] = []
|
||||||
|
var shares: [ShareRecord] = []
|
||||||
|
var search = ""
|
||||||
|
var crawling = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
do {
|
||||||
|
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
|
||||||
|
} catch {
|
||||||
|
engine = nil
|
||||||
|
startupError = String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application Support survives updates and is backed up, which is what
|
||||||
|
/// the watch history wants. The block cache goes under Caches, which iOS
|
||||||
|
/// may purge and never backs up.
|
||||||
|
private static func paths() throws -> AndroidPaths {
|
||||||
|
let files = FileManager.default
|
||||||
|
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let config = support.appendingPathComponent("config", isDirectory: true)
|
||||||
|
let data = support.appendingPathComponent("data", isDirectory: true)
|
||||||
|
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
|
||||||
|
for directory in [config, data, cache] {
|
||||||
|
try files.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoEngine: Error, CustomStringConvertible {
|
||||||
|
var description: String { "the Rust engine did not start" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a blocking bridge call off the main thread. The synchronous exports
|
||||||
|
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
|
||||||
|
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T {
|
||||||
|
guard let engine else { throw NoEngine() }
|
||||||
|
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(_ error: Error) {
|
||||||
|
self.error = String(describing: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reload() async {
|
||||||
|
let needle = search.trimmingCharacters(in: .whitespaces)
|
||||||
|
do {
|
||||||
|
let (titles, shares) = try await run { engine in
|
||||||
|
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
|
||||||
|
}
|
||||||
|
self.titles = titles
|
||||||
|
self.shares = shares
|
||||||
|
} catch {
|
||||||
|
report(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func crawl(shareId: String? = nil) async {
|
||||||
|
guard let engine, !crawling else { return }
|
||||||
|
crawling = true
|
||||||
|
defer { crawling = false }
|
||||||
|
do {
|
||||||
|
try await engine.crawl(shareId: shareId)
|
||||||
|
} catch {
|
||||||
|
report(error)
|
||||||
|
}
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addShare(name: String, url: String, password: String?) async {
|
||||||
|
do {
|
||||||
|
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
||||||
|
await crawl(shareId: share.id)
|
||||||
|
} catch {
|
||||||
|
report(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairShare(id: String, url: String, password: String?) async {
|
||||||
|
do {
|
||||||
|
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||||
|
await crawl(shareId: id)
|
||||||
|
} catch {
|
||||||
|
report(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeShare(id: String) async {
|
||||||
|
do {
|
||||||
|
try await run { try $0.removeShare(shareId: id) }
|
||||||
|
} catch {
|
||||||
|
report(error)
|
||||||
|
}
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marking seen puts the position at the duration where one is known, and
|
||||||
|
/// unwatching rewinds, as on desktop and Android.
|
||||||
|
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
|
||||||
|
do {
|
||||||
|
try await run { engine in
|
||||||
|
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
|
||||||
|
try engine.saveWatchState(
|
||||||
|
shareId: episode.shareId, linkId: episode.linkId,
|
||||||
|
positionSecs: watched ? duration ?? 0 : 0,
|
||||||
|
durationSecs: duration, watched: watched
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
report(error)
|
||||||
|
}
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Episodes started but not finished, most recent first.
|
||||||
|
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
|
||||||
|
titles
|
||||||
|
.flatMap { title in
|
||||||
|
title.seasons.flatMap(\.episodes)
|
||||||
|
.filter { $0.resumeAt != nil && !$0.watched }
|
||||||
|
.map { (title: title, episode: $0) }
|
||||||
|
}
|
||||||
|
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// The share URL fragment, custom passwords and metadata API keys, held in the
|
||||||
|
/// iOS Keychain. The counterpart of Android's `KeystoreSecretStore`.
|
||||||
|
///
|
||||||
|
/// Anything thrown from here reaches Rust as `UnexpectedUniFFICallbackError`,
|
||||||
|
/// which the bridge turns into an ordinary `BridgeError` rather than a panic on
|
||||||
|
/// whichever tokio worker asked (B18).
|
||||||
|
final class KeychainSecretStore: AndroidSecretStore, @unchecked Sendable {
|
||||||
|
private let service = "io.narl.protonstream.secrets"
|
||||||
|
|
||||||
|
struct KeychainError: Error, CustomStringConvertible {
|
||||||
|
let status: OSStatus
|
||||||
|
let action: String
|
||||||
|
var description: String {
|
||||||
|
let message = SecCopyErrorMessageString(status, nil) as String? ?? "status \(status)"
|
||||||
|
return "keychain \(action): \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func query(_ key: String) -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(key: String, value: String) throws {
|
||||||
|
let data = Data(value.utf8)
|
||||||
|
let update = SecItemUpdate(query(key) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||||
|
if update == errSecSuccess { return }
|
||||||
|
guard update == errSecItemNotFound else { throw KeychainError(status: update, action: "update") }
|
||||||
|
var item = query(key)
|
||||||
|
item[kSecValueData as String] = data
|
||||||
|
// Readable by the refresh that runs while the phone is locked in a
|
||||||
|
// pocket, and never restored onto another device from a backup.
|
||||||
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let add = SecItemAdd(item as CFDictionary, nil)
|
||||||
|
guard add == errSecSuccess else { throw KeychainError(status: add, action: "add") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(key: String) throws -> String? {
|
||||||
|
var item = query(key)
|
||||||
|
item[kSecReturnData as String] = true
|
||||||
|
item[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(item as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return nil }
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else {
|
||||||
|
throw KeychainError(status: status, action: "read")
|
||||||
|
}
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String) throws {
|
||||||
|
let status = SecItemDelete(query(key) as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw KeychainError(status: status, action: "delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ProtonStreamApp: App {
|
||||||
|
@State private var model = AppModel()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// `.playback` keeps the audio going with the screen locked or the app
|
||||||
|
// in the background, which `UIBackgroundModes: audio` then permits.
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environment(model)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let failure = model.startupError {
|
||||||
|
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
|
||||||
|
} else {
|
||||||
|
TabView {
|
||||||
|
NavigationStack { LibraryView() }
|
||||||
|
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
|
||||||
|
NavigationStack { SharesView() }
|
||||||
|
.tabItem { Label("Shares", systemImage: "link") }
|
||||||
|
NavigationStack { SettingsView() }
|
||||||
|
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||||
|
}
|
||||||
|
.task { await model.reload() }
|
||||||
|
.alert("Something went wrong", isPresented: .init(
|
||||||
|
get: { model.error != nil },
|
||||||
|
set: { if !$0 { model.error = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(model.error ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// C declarations Swift sees without a module import: the UniFFI scaffolding
|
||||||
|
// generated beside PstrBridge.swift, and the stream ABI UniFFI does not cover.
|
||||||
|
#include "PstrBridgeFFI.h"
|
||||||
|
#include "pstr_stream.h"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// The blocking stream ABI exported by crates/pstr-android (`lib.rs`,
|
||||||
|
// `pstr_android_stream_*`). It is not part of the UniFFI interface on purpose:
|
||||||
|
// decrypted bytes go straight from Rust into libmpv's buffer and never pass
|
||||||
|
// through Swift values.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/// Read up to `length` bytes at `offset` into `buffer`. Returns the byte count,
|
||||||
|
/// 0 at end of file, or -1 on failure or cancellation.
|
||||||
|
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
|
||||||
|
|
||||||
|
/// The stream's size in bytes, or -1 for an unknown handle.
|
||||||
|
int64_t pstr_android_stream_size(uint64_t handle);
|
||||||
|
|
||||||
|
/// Interrupt the read in flight for this stream, if any.
|
||||||
|
void pstr_android_stream_cancel(uint64_t handle);
|
||||||
|
|
||||||
|
/// Drop the registry's reference. Call exactly once per published handle.
|
||||||
|
void pstr_android_stream_release(uint64_t handle);
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libmpv
|
||||||
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// MoltenVK can set the drawable to 1x1 to force a presentation through, which
|
||||||
|
/// flickers and can stick. MPVKit's demo carries the same guard
|
||||||
|
/// (mpv-player/mpv#13651).
|
||||||
|
final class MpvMetalLayer: CAMetalLayer {
|
||||||
|
override var drawableSize: CGSize {
|
||||||
|
get { super.drawableSize }
|
||||||
|
set {
|
||||||
|
if Int(newValue.width) > 1, Int(newValue.height) > 1 {
|
||||||
|
super.drawableSize = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MpvTrack: Identifiable, Hashable {
|
||||||
|
let id: Int64
|
||||||
|
let type: String
|
||||||
|
let language: String
|
||||||
|
let title: String
|
||||||
|
let selected: Bool
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
|
||||||
|
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One libmpv core rendering into a Metal layer. Every public method runs on
|
||||||
|
/// the main thread; mpv's events are read on a thread of their own and applied
|
||||||
|
/// back on the main thread.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class MpvPlayer {
|
||||||
|
enum EndReason { case eof, stopped, failed }
|
||||||
|
|
||||||
|
private(set) var position: Double = 0
|
||||||
|
private(set) var duration: Double = 0
|
||||||
|
private(set) var paused = false
|
||||||
|
private(set) var buffering = false
|
||||||
|
private(set) var tracks: [MpvTrack] = []
|
||||||
|
private(set) var chapters: [ChapterRecord] = []
|
||||||
|
private(set) var failure: String?
|
||||||
|
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
|
||||||
|
/// that window still describes the *outgoing* file, including the
|
||||||
|
/// `END_FILE(stop)` stopping it emits.
|
||||||
|
private(set) var loading = false
|
||||||
|
|
||||||
|
/// Called once per file that ends, never for the one a load replaced.
|
||||||
|
@ObservationIgnored var onEnded: ((EndReason) -> Void)?
|
||||||
|
|
||||||
|
@ObservationIgnored let layer = MpvMetalLayer()
|
||||||
|
@ObservationIgnored private var mpv: OpaquePointer?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
layer.framebufferOnly = true
|
||||||
|
layer.backgroundColor = UIColor.black.cgColor
|
||||||
|
guard let mpv = mpv_create() else {
|
||||||
|
failure = "libmpv did not start"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wid = layer
|
||||||
|
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
|
||||||
|
for (name, value) in [
|
||||||
|
("config", "no"),
|
||||||
|
("vo", "gpu-next"),
|
||||||
|
("gpu-api", "vulkan"),
|
||||||
|
("gpu-context", "moltenvk"),
|
||||||
|
("hwdec", "videotoolbox"),
|
||||||
|
("video-rotate", "no"),
|
||||||
|
("keep-open", "no"),
|
||||||
|
("cache", "yes"),
|
||||||
|
("cache-secs", "30"),
|
||||||
|
("demuxer-readahead-secs", "30"),
|
||||||
|
("demuxer-max-bytes", "50331648"),
|
||||||
|
("subs-fallback", "yes"),
|
||||||
|
] {
|
||||||
|
mpv_set_option_string(mpv, name, value)
|
||||||
|
}
|
||||||
|
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
|
||||||
|
mpv_terminate_destroy(mpv)
|
||||||
|
failure = "libmpv did not initialize"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
|
||||||
|
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
|
||||||
|
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
|
||||||
|
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
|
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
|
||||||
|
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
|
||||||
|
mpv_request_log_messages(mpv, "error")
|
||||||
|
self.mpv = mpv
|
||||||
|
|
||||||
|
let thread = Thread { [weak self] in Self.eventLoop(mpv) { event in self?.apply(event) } }
|
||||||
|
thread.name = "mpv-events"
|
||||||
|
thread.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Commands
|
||||||
|
|
||||||
|
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
|
||||||
|
/// still owns the handle's release.
|
||||||
|
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
|
||||||
|
guard mpv != nil else { return false }
|
||||||
|
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
|
||||||
|
setString("alang", audio ?? "")
|
||||||
|
setString("slang", subtitle ?? "")
|
||||||
|
setString("sid", subtitles ? "auto" : "no")
|
||||||
|
position = 0
|
||||||
|
duration = 0
|
||||||
|
tracks = []
|
||||||
|
chapters = []
|
||||||
|
failure = nil
|
||||||
|
loading = true
|
||||||
|
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
||||||
|
loading = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPaused(_ value: Bool) { setFlag("pause", value) }
|
||||||
|
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
|
||||||
|
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
|
||||||
|
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
|
||||||
|
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
|
||||||
|
func setMuted(_ value: Bool) { setFlag("mute", value) }
|
||||||
|
|
||||||
|
func select(audio: Bool, track: MpvTrack?) {
|
||||||
|
let property = audio ? "aid" : "sid"
|
||||||
|
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
|
||||||
|
refreshTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Showing no picture while backgrounded keeps the audio playing and avoids
|
||||||
|
/// the black frame MoltenVK otherwise leaves on return.
|
||||||
|
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
|
||||||
|
|
||||||
|
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
|
||||||
|
/// block fetch, and the event thread destroys the core once it is down.
|
||||||
|
func shutdown() {
|
||||||
|
guard let mpv else { return }
|
||||||
|
self.mpv = nil
|
||||||
|
mpv_command_string(mpv, "quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Events
|
||||||
|
|
||||||
|
private enum Event {
|
||||||
|
case double(UInt64, Double)
|
||||||
|
case flag(UInt64, Bool)
|
||||||
|
case count(UInt64)
|
||||||
|
case loaded
|
||||||
|
case ended(Int32)
|
||||||
|
case log(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs until the core shuts down, then destroys it. Nothing else calls
|
||||||
|
/// `mpv_terminate_destroy`, so no main-thread call can race it.
|
||||||
|
private nonisolated static func eventLoop(_ mpv: OpaquePointer, _ deliver: @escaping @MainActor (Event) -> Void) {
|
||||||
|
while true {
|
||||||
|
guard let event = mpv_wait_event(mpv, -1)?.pointee else { continue }
|
||||||
|
var decoded: Event?
|
||||||
|
switch event.event_id {
|
||||||
|
case MPV_EVENT_SHUTDOWN:
|
||||||
|
mpv_terminate_destroy(mpv)
|
||||||
|
return
|
||||||
|
case MPV_EVENT_PROPERTY_CHANGE:
|
||||||
|
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
|
||||||
|
let data = property.data else { break }
|
||||||
|
switch property.format {
|
||||||
|
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
|
||||||
|
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
|
||||||
|
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case MPV_EVENT_FILE_LOADED:
|
||||||
|
decoded = .loaded
|
||||||
|
case MPV_EVENT_END_FILE:
|
||||||
|
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
||||||
|
decoded = .ended(end.reason.rawValue)
|
||||||
|
}
|
||||||
|
case MPV_EVENT_LOG_MESSAGE:
|
||||||
|
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
|
||||||
|
let text = message.text {
|
||||||
|
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if let decoded {
|
||||||
|
DispatchQueue.main.async { deliver(decoded) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ event: Event) {
|
||||||
|
switch event {
|
||||||
|
case let .double(id, value):
|
||||||
|
if loading { return }
|
||||||
|
if id == 1 { position = value } else if id == 2 { duration = value }
|
||||||
|
case let .flag(id, value):
|
||||||
|
if id == 3 { paused = value } else if id == 4 { buffering = value }
|
||||||
|
case let .count(id):
|
||||||
|
if loading { return }
|
||||||
|
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() }
|
||||||
|
case .loaded:
|
||||||
|
loading = false
|
||||||
|
refreshTracks()
|
||||||
|
refreshChapters()
|
||||||
|
case let .ended(reason):
|
||||||
|
// The outgoing file's END_FILE arrives after the next load began.
|
||||||
|
if loading { return }
|
||||||
|
switch reason {
|
||||||
|
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
|
||||||
|
case MPV_END_FILE_REASON_ERROR.rawValue:
|
||||||
|
failure = failure ?? "The file could not be played"
|
||||||
|
onEnded?(.failed)
|
||||||
|
default: onEnded?(.stopped)
|
||||||
|
}
|
||||||
|
case let .log(text):
|
||||||
|
if !text.isEmpty { failure = text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshTracks() {
|
||||||
|
let count = getInt("track-list/count")
|
||||||
|
tracks = (0 ..< max(0, count)).map { index in
|
||||||
|
let base = "track-list/\(index)/"
|
||||||
|
return MpvTrack(
|
||||||
|
id: getInt(base + "id"),
|
||||||
|
type: getString(base + "type") ?? "",
|
||||||
|
language: getString(base + "lang") ?? "",
|
||||||
|
title: getString(base + "title") ?? "",
|
||||||
|
selected: getString(base + "selected") == "yes"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshChapters() {
|
||||||
|
let count = getInt("chapter-list/count")
|
||||||
|
chapters = (0 ..< max(0, count)).map { index in
|
||||||
|
let base = "chapter-list/\(index)/"
|
||||||
|
return ChapterRecord(index: index, title: getString(base + "title"), start: getDouble(base + "time"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Property plumbing
|
||||||
|
|
||||||
|
private func command(_ args: [String]) -> Int32 {
|
||||||
|
guard let mpv else { return -1 }
|
||||||
|
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
|
||||||
|
pointers.append(nil)
|
||||||
|
defer { pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) } }
|
||||||
|
return mpv_command(mpv, &pointers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setString(_ name: String, _ value: String) {
|
||||||
|
guard let mpv else { return }
|
||||||
|
mpv_set_property_string(mpv, name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setDouble(_ name: String, _ value: Double) {
|
||||||
|
guard let mpv else { return }
|
||||||
|
var value = value
|
||||||
|
mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setFlag(_ name: String, _ value: Bool) {
|
||||||
|
guard let mpv else { return }
|
||||||
|
var flag: Int32 = value ? 1 : 0
|
||||||
|
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getInt(_ name: String) -> Int64 {
|
||||||
|
guard let mpv else { return 0 }
|
||||||
|
var value: Int64 = 0
|
||||||
|
mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDouble(_ name: String) -> Double {
|
||||||
|
guard let mpv else { return 0 }
|
||||||
|
var value = 0.0
|
||||||
|
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getString(_ name: String) -> String? {
|
||||||
|
guard let mpv, let raw = mpv_get_property_string(mpv, name) else { return nil }
|
||||||
|
defer { mpv_free(raw) }
|
||||||
|
return String(cString: raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libmpv
|
||||||
|
|
||||||
|
// libmpv's `pstr://<handle>` protocol, backed by the Rust stream registry. The
|
||||||
|
// Swift port of the stream half of android/app/src/main/cpp/pstr_mpv.cpp.
|
||||||
|
|
||||||
|
/// One open file on mpv's demuxer thread. `position` is only ever touched from
|
||||||
|
/// that thread; `cancelled` is also written from whichever thread cancels.
|
||||||
|
private final class StreamCookie {
|
||||||
|
let handle: UInt64
|
||||||
|
let size: Int64
|
||||||
|
var position: Int64 = 0
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _cancelled = false
|
||||||
|
|
||||||
|
init(handle: UInt64, size: Int64) {
|
||||||
|
self.handle = handle
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancelled: Bool {
|
||||||
|
get { lock.withLock { _cancelled } }
|
||||||
|
set { lock.withLock { _cancelled = newValue } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cookie(_ opaque: UnsafeMutableRawPointer?) -> StreamCookie? {
|
||||||
|
opaque.map { Unmanaged<StreamCookie>.fromOpaque($0).takeUnretainedValue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the protocol on a core that has not been initialized yet.
|
||||||
|
func registerStreamProtocol(_ mpv: OpaquePointer) -> Bool {
|
||||||
|
mpv_stream_cb_add_ro(mpv, "pstr", nil, streamOpen) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func streamOpen(
|
||||||
|
_: UnsafeMutableRawPointer?,
|
||||||
|
_ uri: UnsafeMutablePointer<CChar>?,
|
||||||
|
_ info: UnsafeMutablePointer<mpv_stream_cb_info>?
|
||||||
|
) -> Int32 {
|
||||||
|
let failed = MPV_ERROR_LOADING_FAILED.rawValue
|
||||||
|
guard let uri, let info else { return failed }
|
||||||
|
let value = String(cString: uri)
|
||||||
|
guard value.hasPrefix("pstr://"), let handle = UInt64(value.dropFirst("pstr://".count)) else { return failed }
|
||||||
|
let size = pstr_android_stream_size(handle)
|
||||||
|
guard size >= 0 else { return failed }
|
||||||
|
|
||||||
|
info.pointee.cookie = Unmanaged.passRetained(StreamCookie(handle: handle, size: size)).toOpaque()
|
||||||
|
info.pointee.read_fn = { opaque, buffer, length in
|
||||||
|
guard let stream = cookie(opaque), let buffer else { return -1 }
|
||||||
|
if stream.cancelled { return -1 }
|
||||||
|
let remaining = stream.size - stream.position
|
||||||
|
if remaining <= 0 { return 0 }
|
||||||
|
let requested = Int(min(length, UInt64(remaining)))
|
||||||
|
let read = pstr_android_stream_read(stream.handle, UInt64(stream.position), buffer, requested)
|
||||||
|
if read > 0 { stream.position += read }
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
info.pointee.seek_fn = { opaque, offset in
|
||||||
|
guard let stream = cookie(opaque), offset >= 0, offset <= stream.size else {
|
||||||
|
return Int64(MPV_ERROR_GENERIC.rawValue)
|
||||||
|
}
|
||||||
|
stream.position = offset
|
||||||
|
stream.cancelled = false
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
info.pointee.size_fn = { opaque in
|
||||||
|
cookie(opaque)?.size ?? Int64(MPV_ERROR_GENERIC.rawValue)
|
||||||
|
}
|
||||||
|
// The flag stops the next read; the one already parked in a 4 MiB block
|
||||||
|
// fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
|
||||||
|
// out the whole fetch while joining the demuxer thread (B16).
|
||||||
|
info.pointee.cancel_fn = { opaque in
|
||||||
|
guard let stream = cookie(opaque) else { return }
|
||||||
|
stream.cancelled = true
|
||||||
|
pstr_android_stream_cancel(stream.handle)
|
||||||
|
}
|
||||||
|
// The one moment defined to be after mpv's last read of this file, so the
|
||||||
|
// Rust stream is released here and nowhere else (B15/B28).
|
||||||
|
info.pointee.close_fn = { opaque in
|
||||||
|
guard let opaque else { return }
|
||||||
|
let stream = Unmanaged<StreamCookie>.fromOpaque(opaque).takeRetainedValue()
|
||||||
|
pstr_android_stream_release(stream.handle)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# XcodeGen spec for the iOS client. The .xcodeproj and Info.plist are generated
|
||||||
|
# from this by scripts/build-ios.sh and are not committed.
|
||||||
|
name: ProtonStream
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: io.narl
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "17.0"
|
||||||
|
|
||||||
|
packages:
|
||||||
|
MPVKit:
|
||||||
|
url: https://github.com/mpvkit/MPVKit
|
||||||
|
exactVersion: 1.0.0
|
||||||
|
|
||||||
|
targets:
|
||||||
|
ProtonStream:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: ProtonStream
|
||||||
|
# UniFFI output, written by `scripts/build-ios.sh bindings`.
|
||||||
|
- path: build/generated/PstrBridge.swift
|
||||||
|
dependencies:
|
||||||
|
# The GPL build is the one with libass and every demuxer; the app is
|
||||||
|
# sideloaded, never distributed through the App Store.
|
||||||
|
- package: MPVKit
|
||||||
|
product: MPVKit-GPL
|
||||||
|
- sdk: Security.framework
|
||||||
|
- sdk: SystemConfiguration.framework
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream
|
||||||
|
MARKETING_VERSION: ${PSTR_VERSION}
|
||||||
|
CURRENT_PROJECT_VERSION: ${PSTR_BUILD}
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h
|
||||||
|
HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated
|
||||||
|
LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/../target/aarch64-apple-ios/release
|
||||||
|
OTHER_LDFLAGS: -lpstr_android
|
||||||
|
info:
|
||||||
|
path: ProtonStream/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Proton Stream
|
||||||
|
LSRequiresIPhoneOS: true
|
||||||
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
UIBackgroundModes: [audio]
|
||||||
|
UILaunchScreen: {}
|
||||||
|
UIRequiresFullScreen: false
|
||||||
|
UISupportedInterfaceOrientations:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
UISupportedInterfaceOrientations~ipad:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
# builds from the registry alone.
|
# builds from the registry alone.
|
||||||
|
|
||||||
pkgname=proton-stream
|
pkgname=proton-stream
|
||||||
pkgver=0.4.2
|
pkgver=0.5.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the iOS client as an unsigned .ipa for SideStore / AltStore.
|
||||||
|
#
|
||||||
|
# SideStore re-signs whatever it installs with the viewer's own Apple ID, so
|
||||||
|
# nothing here touches a certificate or a provisioning profile. Needs macOS with
|
||||||
|
# Xcode, XcodeGen and the aarch64-apple-ios Rust target.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd -- "$script_dir/.." && pwd)"
|
||||||
|
ios_dir="$repo_root/ios"
|
||||||
|
generated="$ios_dir/build/generated"
|
||||||
|
target=aarch64-apple-ios
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 {rust|bindings|ipa|all}"
|
||||||
|
echo
|
||||||
|
echo " rust static libpstr_android.a for $target"
|
||||||
|
echo " bindings Swift bindings into ios/build/generated"
|
||||||
|
echo " ipa xcodegen + xcodebuild, then dist/ProtonStream-<version>.ipa"
|
||||||
|
echo " all all three, in that order"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
[[ "$(uname -s)" == Darwin ]] || die "the iOS build needs macOS and Xcode"
|
||||||
|
|
||||||
|
# Same guard as scripts/build.sh: without the sibling checkout the resolve fails
|
||||||
|
# a hundred lines deep in cargo output.
|
||||||
|
if grep -q '^\[patch\.crates-io\]' "$repo_root/Cargo.toml" && [[ ! -d "$repo_root/../proton-sdk-rs" ]]; then
|
||||||
|
die "Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing."
|
||||||
|
fi
|
||||||
|
|
||||||
|
version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} found && /^version = /{gsub(/[" ]/, "", $3); print $3; exit}' "$repo_root/Cargo.toml")"
|
||||||
|
[[ -n "$version" ]] || die "no [workspace.package] version in Cargo.toml"
|
||||||
|
|
||||||
|
build_rust() {
|
||||||
|
log "cargo rustc -p pstr-android --target $target (staticlib)"
|
||||||
|
# The crate stays `cdylib, rlib` for Android; the archive iOS links is asked
|
||||||
|
# for here instead of being built on every other platform too.
|
||||||
|
( cd "$repo_root" && IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo rustc -p pstr-android --lib \
|
||||||
|
--release --locked --target "$target" --crate-type staticlib )
|
||||||
|
}
|
||||||
|
|
||||||
|
build_bindings() {
|
||||||
|
log "uniffi-bindgen --language swift"
|
||||||
|
# Library mode reads the interface out of a built library, and the host
|
||||||
|
# dylib is the one the bindgen binary can open.
|
||||||
|
( cd "$repo_root" && cargo build -p pstr-android --lib --locked )
|
||||||
|
rm -rf "$generated"
|
||||||
|
( cd "$repo_root" && cargo run -p pstr-android --features bindgen --bin uniffi-bindgen --locked -- \
|
||||||
|
generate --library target/debug/libpstr_android.dylib --language swift \
|
||||||
|
--no-format --out-dir "$generated" )
|
||||||
|
[[ -f "$generated/PstrBridge.swift" ]] || die "bindgen wrote no PstrBridge.swift"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_ipa() {
|
||||||
|
command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)"
|
||||||
|
[[ -f "$repo_root/target/$target/release/libpstr_android.a" ]] || die "run '$0 rust' first"
|
||||||
|
[[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first"
|
||||||
|
|
||||||
|
log "xcodegen ($version)"
|
||||||
|
( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet )
|
||||||
|
|
||||||
|
log "xcodebuild (unsigned)"
|
||||||
|
xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \
|
||||||
|
-configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \
|
||||||
|
-derivedDataPath "$ios_dir/build/DerivedData" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \
|
||||||
|
build
|
||||||
|
|
||||||
|
local app="$ios_dir/build/DerivedData/Build/Products/Release-iphoneos/ProtonStream.app"
|
||||||
|
[[ -d "$app" ]] || die "xcodebuild produced no ProtonStream.app"
|
||||||
|
local stage="$ios_dir/build/ipa"
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir -p "$stage/Payload" "$repo_root/dist"
|
||||||
|
cp -R "$app" "$stage/Payload/"
|
||||||
|
local ipa="$repo_root/dist/ProtonStream-$version.ipa"
|
||||||
|
rm -f "$ipa"
|
||||||
|
( cd "$stage" && zip -qry "$ipa" Payload )
|
||||||
|
log "wrote $ipa"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
rust) build_rust ;;
|
||||||
|
bindings) build_bindings ;;
|
||||||
|
ipa) build_ipa ;;
|
||||||
|
all) build_rust; build_bindings; build_ipa ;;
|
||||||
|
*) usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
Reference in new issue
Block a user