mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e09129d0c | ||
|
|
476fec8c04 | ||
|
|
7af0969e38 | ||
|
|
3df0644bd8 | ||
|
|
5d079b05c9 | ||
|
|
4d44db0949 | ||
|
|
4a54af3245 | ||
|
|
7e734dbf19 | ||
|
|
e7eeaa518f | ||
|
|
39866bee8d | ||
|
|
85092fa893 | ||
|
|
2128a2d705 | ||
|
|
558446b9e4 | ||
|
|
6518aef5ee | ||
|
|
9037e6c2d5 | ||
|
|
d3325843a9 | ||
|
|
79c3f6885d | ||
|
|
a432a93cf9 | ||
|
|
935a985589 | ||
|
|
7d03736954 | ||
|
|
53a1d0afda | ||
|
|
959547cdc8 | ||
|
|
c60ffc7ab1 | ||
|
|
c5808a5e50 | ||
|
|
41d1fbf073 | ||
|
|
ff362d0d08 | ||
|
|
4027ab5fb3 | ||
|
|
c5adcddb1e | ||
|
|
8f78f5c1b1 | ||
|
|
6f28a9c377 | ||
|
|
d3c5f071fb | ||
|
|
ad24557588 | ||
|
|
4e6778e1ea | ||
|
|
dc85890a9f | ||
|
|
4ef62255c0 | ||
|
|
1a9a4624f8 | ||
|
|
cb03f96eca | ||
|
|
46bb68e3a5 | ||
|
|
f1b3ad9637 |
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
+252
-45
@@ -119,12 +119,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android-activity"
|
name = "android-activity"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -609,6 +603,12 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1029,6 +1029,36 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"block",
|
||||||
|
"cocoa-foundation",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics 0.22.3",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa-foundation"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"block",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics-types",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -1136,6 +1166,19 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-graphics"
|
||||||
|
version = "0.22.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics-types",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics"
|
name = "core-graphics"
|
||||||
version = "0.23.2"
|
version = "0.23.2"
|
||||||
@@ -1145,7 +1188,7 @@ dependencies = [
|
|||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1377,6 +1420,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dbus-crossroads"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8"
|
||||||
|
dependencies = [
|
||||||
|
"dbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus-secret-service"
|
name = "dbus-secret-service"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -1709,6 +1761,15 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "egui-phosphor"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
|
||||||
|
dependencies = [
|
||||||
|
"egui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-wgpu"
|
name = "egui-wgpu"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@@ -2003,12 +2064,6 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2024,6 +2079,15 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2031,7 +2095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2045,6 +2109,12 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2279,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"log",
|
"log",
|
||||||
"peniko",
|
"peniko",
|
||||||
@@ -2403,6 +2473,25 @@ dependencies = [
|
|||||||
"euclid",
|
"euclid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -2436,24 +2525,13 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash 0.1.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2462,7 +2540,7 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2592,6 +2670,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -3150,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.18.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3163,6 +3242,15 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3483,6 +3571,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||||
|
dependencies = [
|
||||||
|
"malloc_buf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -4202,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.4.0"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b162681255e296b48ef7261cad899ffb384756e866a1c6d7b7c791cbbc8af3ac"
|
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -4229,13 +4326,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.4.0"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32d9b94ec901edb0e849238ea833121f2fb173308b4927b8aa6b157643fa0a73"
|
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4262,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4281,11 +4378,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"dbus",
|
||||||
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
"egui-phosphor",
|
||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4293,17 +4394,22 @@ dependencies = [
|
|||||||
"pstr-meta",
|
"pstr-meta",
|
||||||
"pstr-player",
|
"pstr-player",
|
||||||
"pstr-stream",
|
"pstr-stream",
|
||||||
|
"raw-window-handle",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"skrifa",
|
||||||
|
"souvlaki",
|
||||||
|
"tauri-winrt-notification",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4321,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4339,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4352,12 +4458,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
"libmpv2",
|
"libmpv2",
|
||||||
"libmpv2-sys",
|
"libmpv2-sys",
|
||||||
|
"pstr-core",
|
||||||
"pstr-stream",
|
"pstr-stream",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4366,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4646,6 +4753,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -5400,6 +5508,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "souvlaki"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"block",
|
||||||
|
"cocoa",
|
||||||
|
"core-graphics 0.22.3",
|
||||||
|
"dbus",
|
||||||
|
"dbus-crossroads",
|
||||||
|
"dispatch",
|
||||||
|
"objc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"windows 0.44.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
@@ -5488,6 +5614,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"windows 0.62.2",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5643,6 +5780,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -6538,6 +6676,36 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.44.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -6551,6 +6719,17 @@ dependencies = [
|
|||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -6579,6 +6758,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6714,6 +6903,24 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-version"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6910,7 +7117,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics",
|
"core-graphics 0.23.2",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+23
-4
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.4.0" }
|
proton-sdk = { version = "0.7.2" }
|
||||||
proton-drive-rs = { version = "0.4.0" }
|
proton-drive-rs = { version = "0.7.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -51,6 +51,8 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
# Local dates for the history page. Already in the tree through the SDK.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
|
|||||||
"x11",
|
"x11",
|
||||||
] }
|
] }
|
||||||
egui = "0.35"
|
egui = "0.35"
|
||||||
|
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
|
||||||
|
# font has them, at its own size and baseline; an icon font is one set.
|
||||||
|
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
|
||||||
|
# Tests only: reads the bundled fonts' character maps. The version epaint
|
||||||
|
# already builds, so it costs no extra compile.
|
||||||
|
skrifa = { version = "0.42", default-features = false, features = ["std"] }
|
||||||
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
||||||
# pulls in every exotic format is weight this app has no use for.
|
# pulls in every exotic format is weight this app has no use for.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
@@ -84,9 +92,20 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
# behind a poisoned lock for the rest of the session. Same reason
|
# behind a poisoned lock for the rest of the session. Same reason
|
||||||
# `proton-drive-linux` uses it.
|
# `proton-drive-linux` uses it.
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows.
|
||||||
|
# `dbus` rather than `zbus` because the keyring's Secret Service store already
|
||||||
|
# links libdbus, so this adds no second D-Bus stack.
|
||||||
|
souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] }
|
||||||
|
# Holding the screensaver off while a film plays. mpv does that for a window of
|
||||||
|
# its own, but the picture here is drawn into ours, and mpv has no window to
|
||||||
|
# hold it for.
|
||||||
|
dbus = "0.9"
|
||||||
|
windows-sys = "0.61"
|
||||||
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
|
tauri-winrt-notification = "0.8"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.12"
|
lru = "0.18"
|
||||||
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
||||||
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
||||||
# be tested against a fake without a network or an account.
|
# be tested against a fake without a network or an account.
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
One window at a time: a second launch brings the open window forward and
|
||||||
|
hands it any share link it was given, then exits.
|
||||||
|
|
||||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||||
is broken:
|
is broken:
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ against the SDK's own range download.
|
|||||||
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
||||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. |
|
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
||||||
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
||||||
|
|
||||||
@@ -103,11 +107,21 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
a local HTTP server: there is no port for another process on the machine to ask
|
a local HTTP server: there is no port for another process on the machine to ask
|
||||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
||||||
would put a share secret in its log and window title.
|
would put a share secret in its log and window title.
|
||||||
- The block cache holds decrypted content. It lives in your cache directory and
|
- The block cache holds decrypted content. It lives in your cache directory,
|
||||||
can be deleted at any time the app is not running.
|
can be deleted at any time the app is not running, and can be sized or
|
||||||
|
emptied from Settings → Storage.
|
||||||
|
- A share link given as a command-line argument is visible to other users of
|
||||||
|
the machine in the process list while that launch runs, and may land in
|
||||||
|
shell history. Pasting it into the window (Ctrl+V) does neither.
|
||||||
|
- The desktop's media widget is handed the title, the episode and a `file://`
|
||||||
|
path to artwork already on disk — never a provider URL, so the shell never
|
||||||
|
contacts a metadata provider on its own.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
application and its combined native distribution are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||||
|
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# ktlint configuration.
|
||||||
|
#
|
||||||
|
# The whole standard ruleset is off, and rules are enabled one at a time. That
|
||||||
|
# is deliberate: turning it all on reports ~540 findings on the existing tree,
|
||||||
|
# almost all of them line-wrapping preference, and a mechanical reformat of
|
||||||
|
# every file would bury the changes that matter in review. The rules kept here
|
||||||
|
# are the ones whose violation is a defect or a real maintenance cost rather
|
||||||
|
# than a matter of taste — starting with the unused imports that were the
|
||||||
|
# evidence no gate ran at all.
|
||||||
|
#
|
||||||
|
# Adding a rule is cheap. Do it with the reformat it implies in its own commit.
|
||||||
|
|
||||||
|
[*.{kt,kts}]
|
||||||
|
ktlint_standard = disabled
|
||||||
|
ktlint_standard_no-unused-imports = enabled
|
||||||
|
ktlint_standard_no-wildcard-imports = enabled
|
||||||
|
ktlint_standard_no-semi = enabled
|
||||||
|
ktlint_standard_no-trailing-spaces = enabled
|
||||||
|
ktlint_standard_no-consecutive-blank-lines = enabled
|
||||||
|
ktlint_standard_no-empty-file = enabled
|
||||||
|
ktlint_standard_final-newline = enabled
|
||||||
|
ktlint_standard_filename = enabled
|
||||||
|
ktlint_standard_no-line-break-before-assignment = enabled
|
||||||
|
ktlint_standard_no-multi-spaces = enabled
|
||||||
|
|
||||||
|
# Vendored from rustls-platform-verifier. Restyling it would turn the next sync
|
||||||
|
# with upstream into a merge conflict for no benefit.
|
||||||
|
[**/org/rustls/**.kt]
|
||||||
|
ktlint = disabled
|
||||||
@@ -5,6 +5,7 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ktlint)
|
||||||
}
|
}
|
||||||
|
|
||||||
val repositoryRoot = rootProject.projectDir.parentFile
|
val repositoryRoot = rootProject.projectDir.parentFile
|
||||||
@@ -53,6 +54,7 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = releaseVersionCode
|
versionCode = releaseVersionCode
|
||||||
versionName = releaseVersionName
|
versionName = releaseVersionName
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
@@ -103,7 +105,51 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
|
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
// Applied to the shipping APK on purpose; the file explains the
|
||||||
|
// trade-off and what each keep is repairing.
|
||||||
|
"proguard-instrumentation.pro",
|
||||||
|
)
|
||||||
|
testProguardFiles("proguard-test-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The minified path is what ships, and R8 breaks reflective UniFFI/JNA
|
||||||
|
// surfaces in ways no debug run can see. CI sets this to `release` and runs
|
||||||
|
// connectedAndroidTest against it; locally the default keeps builds fast.
|
||||||
|
testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug"
|
||||||
|
|
||||||
|
lint {
|
||||||
|
warningsAsErrors = true
|
||||||
|
abortOnError = true
|
||||||
|
checkDependencies = true
|
||||||
|
// The baseline records what was already here when the gate went in, so
|
||||||
|
// anything *new* fails. It is not a permanent exemption: every entry in
|
||||||
|
// it maps to an open bug in docs/BUGS.md (the SharedPreferences
|
||||||
|
// `commit()` calls are B20, the PiP one is B40), and the file should get
|
||||||
|
// shorter as those land. Regenerate with `gradlew updateLintBaseline`
|
||||||
|
// after a fix, and never to make an unrelated failure go away.
|
||||||
|
baseline = file("lint-baseline.xml")
|
||||||
|
// Generated UniFFI bindings are not ours to fix.
|
||||||
|
disable += setOf("UnusedIds")
|
||||||
|
// Dependency freshness is not a defect, and as errors these break the
|
||||||
|
// gate whenever someone else cuts a release. Upgrades are a deliberate
|
||||||
|
// act with their own verification; `gradlew lintDebug -Dlint...` or a
|
||||||
|
// dependency bot is the right place to surface them, not CI.
|
||||||
|
informational += setOf(
|
||||||
|
"GradleDependency",
|
||||||
|
"NewerVersionAvailable",
|
||||||
|
"AndroidGradlePluginVersion",
|
||||||
|
)
|
||||||
|
sarifReport = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +255,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.work.runtime)
|
implementation(libs.androidx.work.runtime)
|
||||||
implementation(libs.jna) { artifact { type = "aar" } }
|
implementation(libs.jna) { artifact { type = "aar" } }
|
||||||
|
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
@@ -219,5 +264,36 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material3.adaptive)
|
implementation(libs.androidx.compose.material3.adaptive)
|
||||||
implementation(libs.androidx.compose.material3.navigation.suite)
|
implementation(libs.androidx.compose.material3.navigation.suite)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.test.core)
|
||||||
|
testImplementation(libs.androidx.work.testing)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.androidx.work.testing)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ktlint {
|
||||||
|
version.set("1.5.0")
|
||||||
|
android.set(true)
|
||||||
|
ignoreFailures.set(false)
|
||||||
|
filter {
|
||||||
|
// UniFFI writes these; they are regenerated on every build.
|
||||||
|
exclude { it.file.path.contains("${File.separator}generated${File.separator}") }
|
||||||
|
// Vendored from rustls-platform-verifier. Restyling it would make the
|
||||||
|
// next sync with upstream a merge conflict for no benefit.
|
||||||
|
exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||||
|
// though the filter above drops every file in it. Without this, Gradle refuses
|
||||||
|
// the build for an undeclared dependency between the two tasks.
|
||||||
|
tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }
|
||||||
|
.configureEach { dependsOn(generateUniFfiBindings) }
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<issues format="6" by="lint 8.13.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.13.2)" variant="all" version="8.13.2">
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="AndroidGradlePluginVersion"
|
||||||
|
message="A newer version of com.android.application than 8.13.2 is available: 9.3.1"
|
||||||
|
errorLine1="agp = "8.13.2""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="2"
|
||||||
|
column="7"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.compose:compose-bom than 2026.06.00 is available: 2026.06.01"
|
||||||
|
errorLine1="compose-bom = "2026.06.00""
|
||||||
|
errorLine2=" ~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="4"
|
||||||
|
column="15"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.activity:activity-compose than 1.11.0 is available: 1.13.0"
|
||||||
|
errorLine1="activity = "1.11.0""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="5"
|
||||||
|
column="12"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.9.4 is available: 2.11.0"
|
||||||
|
errorLine1="lifecycle = "2.9.4""
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="6"
|
||||||
|
column="13"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.lifecycle:lifecycle-viewmodel-compose than 2.9.4 is available: 2.11.0"
|
||||||
|
errorLine1="lifecycle = "2.9.4""
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="6"
|
||||||
|
column="13"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.navigation:navigation-compose than 2.9.5 is available: 2.9.8"
|
||||||
|
errorLine1="navigation = "2.9.5""
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="7"
|
||||||
|
column="14"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.work:work-runtime-ktx than 2.10.5 is available: 2.11.2"
|
||||||
|
errorLine1="work = "2.10.5""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="8"
|
||||||
|
column="8"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.work:work-testing than 2.10.5 is available: 2.11.2"
|
||||||
|
errorLine1="work = "2.10.5""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="8"
|
||||||
|
column="8"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.compose.material3.adaptive:adaptive than 1.1.0 is available: 1.2.0"
|
||||||
|
errorLine1="adaptive = "1.1.0""
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="9"
|
||||||
|
column="12"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.compose.material3:material3-adaptive-navigation-suite than 1.1.0 is available: 1.4.0"
|
||||||
|
errorLine1="adaptive = "1.1.0""
|
||||||
|
errorLine2=" ~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="9"
|
||||||
|
column="12"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="GradleDependency"
|
||||||
|
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.19.0"
|
||||||
|
errorLine1="androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }"
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="19"
|
||||||
|
column="64"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="NewerVersionAvailable"
|
||||||
|
message="A newer version of org.jetbrains.kotlin.android than 2.2.21 is available: 2.4.10"
|
||||||
|
errorLine1="kotlin = "2.2.21""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="3"
|
||||||
|
column="10"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="NewerVersionAvailable"
|
||||||
|
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.21 is available: 2.4.10"
|
||||||
|
errorLine1="kotlin = "2.2.21""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="3"
|
||||||
|
column="10"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="NewerVersionAvailable"
|
||||||
|
message="A newer version of net.java.dev.jna:jna than 5.17.0 is available: 5.19.1"
|
||||||
|
errorLine1="jna = "5.17.0""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="10"
|
||||||
|
column="7"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="NewerVersionAvailable"
|
||||||
|
message="A newer version of org.robolectric:robolectric than 4.16 is available: 4.16.1"
|
||||||
|
errorLine1="robolectric = "4.16""
|
||||||
|
errorLine2=" ~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="12"
|
||||||
|
column="15"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="NewerVersionAvailable"
|
||||||
|
message="A newer version of org.jlleitschuh.gradle.ktlint than 13.1.0 is available: 14.2.0"
|
||||||
|
errorLine1="ktlint-plugin = "13.1.0""
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="../gradle/libs.versions.toml"
|
||||||
|
line="16"
|
||||||
|
column="17"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="ObsoleteSdkInt"
|
||||||
|
message="Unnecessary; `SDK_INT` is always >= 31"
|
||||||
|
errorLine1=" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt"
|
||||||
|
line="294"
|
||||||
|
column="13"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="AutoboxingStateCreation"
|
||||||
|
message="Prefer `mutableIntStateOf` instead of `mutableStateOf`"
|
||||||
|
errorLine1=" var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/playback/PlayerScreen.kt"
|
||||||
|
line="707"
|
||||||
|
column="47"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UseKtx"
|
||||||
|
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||||
|
errorLine1=" check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
|
||||||
|
line="41"
|
||||||
|
column="15"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UseKtx"
|
||||||
|
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||||
|
errorLine1=" check(preferences.edit().remove(key).commit())"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
|
||||||
|
line="64"
|
||||||
|
column="15"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UseKtx"
|
||||||
|
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||||
|
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||||
|
line="10"
|
||||||
|
column="22"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UseKtx"
|
||||||
|
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||||
|
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||||
|
line="14"
|
||||||
|
column="22"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UseKtx"
|
||||||
|
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||||
|
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTO_SKIP, value).apply() }"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||||
|
line="25"
|
||||||
|
column="22"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="UseKtx"
|
||||||
|
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||||
|
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTOPLAY, value).apply() }"
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||||
|
line="30"
|
||||||
|
column="22"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
</issues>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Keeps that exist so the instrumentation suite can run against the *release*
|
||||||
|
# build. They are applied to the shipping APK, which is a real trade-off worth
|
||||||
|
# stating plainly: the tested artifact is not byte-identical to an artifact
|
||||||
|
# built without them.
|
||||||
|
#
|
||||||
|
# It is the better side of the trade. The alternative is to run instrumentation
|
||||||
|
# against `debug` only, which leaves R8 — the step that has already broken this
|
||||||
|
# app twice, in ways no debug run can reproduce — entirely uncovered. What is
|
||||||
|
# kept here is a few kilobytes of framework plumbing containing no application
|
||||||
|
# logic and revealing nothing about the app.
|
||||||
|
#
|
||||||
|
# Why these keeps are needed at all: the instrumentation APK is minified in a
|
||||||
|
# separate R8 run that consumes the app's mapping file, and links against the
|
||||||
|
# app's classes at runtime. That arrangement fails in two ways.
|
||||||
|
#
|
||||||
|
# 1. The mapping file carries renames but not R8's parameter-permutation
|
||||||
|
# optimisation, so a permuted method resolves to the wrong descriptor from
|
||||||
|
# the test APK.
|
||||||
|
# 2. A class that only the test APK reaches is unreachable from the app's call
|
||||||
|
# graph, so R8 removes it from the app APK — and the test APK does not
|
||||||
|
# bundle it, because it is supposed to come from the app.
|
||||||
|
|
||||||
|
# The Kotlin runtime is where both failures land, because androidx.test is
|
||||||
|
# itself Kotlin and shares the app's copy of the stdlib. Keeping it wholesale
|
||||||
|
# rather than class by class is deliberate: each individual keep only moves the
|
||||||
|
# crash to the next facade the runner happens to touch, which is a list nobody
|
||||||
|
# can enumerate ahead of time. Three were hit before this rule replaced them:
|
||||||
|
#
|
||||||
|
# NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
|
||||||
|
# in class Lo6/k; (o6.k = kotlin.jvm.internal.Intrinsics,
|
||||||
|
# whose parameters R8 had permuted —
|
||||||
|
# every Kotlin class calls it on entry,
|
||||||
|
# so nothing ran at all)
|
||||||
|
# NoClassDefFoundError: Lkotlin/LazyKt; (androidx.test.platform.io.TestDirCalculator)
|
||||||
|
#
|
||||||
|
# Cost is roughly a megabyte of dex against a ~100 MB APK that is almost
|
||||||
|
# entirely native libraries.
|
||||||
|
-keep class kotlin.** { *; }
|
||||||
|
|
||||||
|
# `AndroidJUnitRunner.onCreate` traces its own startup. Nothing in the app
|
||||||
|
# reaches androidx.tracing, so R8 dropped it and the runner could not start:
|
||||||
|
#
|
||||||
|
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/tracing/Trace;
|
||||||
|
# at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:307)
|
||||||
|
-keep class androidx.tracing.** { *; }
|
||||||
|
|
||||||
|
# A third shape of the same problem, and the one worth understanding: the app
|
||||||
|
# only ever touches WorkManager through its implementation, so R8 vertically
|
||||||
|
# merged the abstract `androidx.work.WorkManager` into `WorkManagerImpl` and the
|
||||||
|
# abstract type stopped existing. It is absent from mapping.txt entirely rather
|
||||||
|
# than renamed. The download tests declare fields of the public types, so JUnit's
|
||||||
|
# field scan could not even construct the test class:
|
||||||
|
#
|
||||||
|
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/work/WorkManager;
|
||||||
|
# at org.junit.runners.model.TestClass.getSortedDeclaredFields(TestClass.java:77)
|
||||||
|
#
|
||||||
|
# Kept package-wide because the same merge can happen to any of the public API
|
||||||
|
# types the tests name (WorkInfo, Data, Constraints), and finding out one crash
|
||||||
|
# at a time costs a four-minute device round trip each.
|
||||||
|
-keep class androidx.work.** { *; }
|
||||||
|
|
||||||
|
# Everything above is framework plumbing. The last group is app code, and it is
|
||||||
|
# the one keep in this file that costs real fidelity: R8 no longer optimises
|
||||||
|
# these classes in the shipping build, so the suite no longer proves R8 handles
|
||||||
|
# them correctly. It is a narrow loss — the surfaces R8 has actually
|
||||||
|
# broken here are the FFI ones, and those are kept for functional reasons in
|
||||||
|
# proguard-rules.pro and stay under test.
|
||||||
|
#
|
||||||
|
# The trigger was `DownloadStateStore.clear()`. It is not dead code — AppViewModel
|
||||||
|
# calls it — but it has exactly one call site, so R8 inlined it and dropped the
|
||||||
|
# method. Only a caller that resolves it by name, which is to say a test, notices:
|
||||||
|
#
|
||||||
|
# java.lang.NoSuchMethodError: No virtual method clear()V
|
||||||
|
# in class Lio/narl/protonstream/download/c;
|
||||||
|
#
|
||||||
|
# Package-wide rather than class-by-class: Kotlin's top-level functions live in
|
||||||
|
# a synthesised `...Kt` facade that no source file names, so keeping the classes
|
||||||
|
# a test imports still leaves it missing `DownloadCoordinatorKt`.
|
||||||
|
-keep class io.narl.protonstream.download.** { *; }
|
||||||
|
|
||||||
|
# Same inlining, one layer out: the `wifiOnly` setter behind the download
|
||||||
|
# constraint.
|
||||||
|
# java.lang.NoSuchMethodError: No virtual method setWifiOnly(Z)V in class Li5/a;
|
||||||
|
-keep class io.narl.protonstream.settings.SettingsStore { *; }
|
||||||
|
|
||||||
|
# The live cases drive the player directly, so the same inlining applies to the
|
||||||
|
# playback classes they name.
|
||||||
|
-keep class io.narl.protonstream.playback.** { *; }
|
||||||
|
|
||||||
|
# `runBlocking` — the live cases drive suspending bridge calls from a JUnit
|
||||||
|
# thread. The app itself never blocks a thread on a coroutine, so R8 drops the
|
||||||
|
# facade that holds it:
|
||||||
|
#
|
||||||
|
# java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/coroutines/BuildersKt;
|
||||||
|
#
|
||||||
|
# Kept narrowly rather than as `kotlinx.coroutines.**`: that library is large and
|
||||||
|
# on the app's hot paths, and pinning all of it would cost real optimisation.
|
||||||
|
-keep class kotlinx.coroutines.BuildersKt** { *; }
|
||||||
Vendored
+16
@@ -7,3 +7,19 @@
|
|||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# UniFFI records are Kotlin data classes whose generic signatures and
|
||||||
|
# annotations survive into the runtime; stripping them changes how the bindings
|
||||||
|
# lift and lower structured types.
|
||||||
|
-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod
|
||||||
|
|
||||||
|
# JNA dispatches into callbacks reflectively by method name.
|
||||||
|
-keepclassmembers class * implements com.sun.jna.Callback {
|
||||||
|
<methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The bridge calls back into these from Rust. Nothing in the Kotlin call graph
|
||||||
|
# reaches their overrides, so R8 is free to rename or drop them.
|
||||||
|
-keep class io.narl.protonstream.native.KeystoreSecretStore { *; }
|
||||||
|
-keep class * implements uniffi.pstr_android.AndroidSecretStore { *; }
|
||||||
|
-keep class * implements uniffi.pstr_android.DownloadObserver { *; }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Applied only to the instrumentation APK when androidTest runs against a
|
||||||
|
# minified build (ANDROID_TEST_BUILD_TYPE=release). The tests exist to prove the
|
||||||
|
# *app's* R8 configuration is right, so nothing here may relax the app's rules —
|
||||||
|
# it only keeps the test harness itself intact.
|
||||||
|
-dontobfuscate
|
||||||
|
-keep class io.narl.protonstream.** { *; }
|
||||||
|
-keep class androidx.test.** { *; }
|
||||||
|
-dontwarn androidx.test.**
|
||||||
|
-keepclasseswithmembers class * {
|
||||||
|
@org.junit.Test <methods>;
|
||||||
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.testing.SynchronousExecutor
|
||||||
|
import androidx.work.testing.WorkManagerTestInitHelper
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What actually reaches WorkManager. The unit tests cover the pure helpers; this
|
||||||
|
* covers the enqueue itself — the input data (which is persisted unencrypted, so
|
||||||
|
* it must carry no secret), the constraints, and the unique-work identity that
|
||||||
|
* pause and resume depend on.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class DownloadCoordinatorInstrumentedTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var workManager: WorkManager
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||||
|
context,
|
||||||
|
Configuration.Builder().setExecutor(SynchronousExecutor()).build(),
|
||||||
|
)
|
||||||
|
workManager = WorkManager.getInstance(context)
|
||||||
|
workManager.cancelAllWork().result.get()
|
||||||
|
DownloadStateStore(context).clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(linkId: String = "link-1", shareId: String = "share-1") = RetainedDownload(
|
||||||
|
shareId = shareId,
|
||||||
|
volumeId = "volume-1",
|
||||||
|
linkId = linkId,
|
||||||
|
label = "S01E01.mkv",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun infoFor(download: RetainedDownload): WorkInfo? =
|
||||||
|
workManager.getWorkInfosForUniqueWork(
|
||||||
|
DownloadCoordinator.workName(download.shareId, download.linkId),
|
||||||
|
).get().firstOrNull()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resumingEnqueuesUniqueWorkAndRetainsAQueuedRecord() {
|
||||||
|
val download = download()
|
||||||
|
DownloadCoordinator.resume(context, download)
|
||||||
|
|
||||||
|
assertNotNull(infoFor(download))
|
||||||
|
assertEquals(
|
||||||
|
RetainedDownload.STATUS_QUEUED,
|
||||||
|
DownloadStateStore(context).get("share-1", "link-1")?.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags are persisted in `androidx.work.workdb` unencrypted and are what the
|
||||||
|
* UI queries and what cancel-by-share matches on. Pinning the exact set is
|
||||||
|
* what makes an accidental addition — a fragment, a URL — visible in review.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun theWorkCarriesExactlyTheExpectedTags() {
|
||||||
|
val download = download()
|
||||||
|
DownloadCoordinator.resume(context, download)
|
||||||
|
|
||||||
|
val tags = infoFor(download)?.tags.orEmpty()
|
||||||
|
assertTrue(DownloadCoordinator.TAG in tags)
|
||||||
|
assertTrue(DownloadCoordinator.shareTag("share-1") in tags)
|
||||||
|
assertTrue("${DownloadCoordinator.EPISODE_TAG}S01E01.mkv" in tags)
|
||||||
|
// The remaining tag is WorkManager's own worker class name.
|
||||||
|
assertEquals(4, tags.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theWifiOnlySettingReachesTheConstraint() {
|
||||||
|
// Documents B29: the constraint is read at enqueue time, so work already
|
||||||
|
// queued keeps whatever was set when it was queued. When B29 is fixed,
|
||||||
|
// this test grows a case for flipping the toggle after enqueue.
|
||||||
|
SettingsStore(context).wifiOnly = true
|
||||||
|
assertEquals(NetworkType.UNMETERED, requiredNetworkType(SettingsStore(context).wifiOnly))
|
||||||
|
SettingsStore(context).wifiOnly = false
|
||||||
|
assertEquals(NetworkType.CONNECTED, requiredNetworkType(SettingsStore(context).wifiOnly))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pauseCancelsTheWorkAndRecordsThePausedStatus() {
|
||||||
|
val download = download()
|
||||||
|
DownloadCoordinator.resume(context, download)
|
||||||
|
DownloadCoordinator.pause(context, download)
|
||||||
|
|
||||||
|
assertTrue(infoFor(download)?.state?.isFinished ?: false)
|
||||||
|
assertEquals(
|
||||||
|
RetainedDownload.STATUS_PAUSED,
|
||||||
|
DownloadStateStore(context).get("share-1", "link-1")?.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancellingOneShareLeavesAnotherSharesWorkAlone() {
|
||||||
|
val first = download(linkId = "a", shareId = "share-1")
|
||||||
|
val second = download(linkId = "b", shareId = "share-2")
|
||||||
|
DownloadCoordinator.resume(context, first)
|
||||||
|
DownloadCoordinator.resume(context, second)
|
||||||
|
|
||||||
|
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag("share-1")).result.get()
|
||||||
|
|
||||||
|
assertTrue(infoFor(first)?.state?.isFinished ?: false)
|
||||||
|
assertEquals(WorkInfo.State.ENQUEUED, infoFor(second)?.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun twoEpisodesOfOneShareEnqueueSeparately() {
|
||||||
|
DownloadCoordinator.resume(context, download(linkId = "a"))
|
||||||
|
DownloadCoordinator.resume(context, download(linkId = "b"))
|
||||||
|
|
||||||
|
assertEquals(2, workManager.getWorkInfosByTag(DownloadCoordinator.TAG).get().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package io.narl.protonstream.live
|
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.view.Surface
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import uniffi.pstr_android.AndroidEngine
|
||||||
|
import uniffi.pstr_android.AndroidPaths
|
||||||
|
import uniffi.pstr_android.AndroidSecretStore
|
||||||
|
import uniffi.pstr_android.DownloadObserver
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cases `docs/ANDROID.md` requires that cannot be answered without a real
|
||||||
|
* share: that a link resolves into a playable catalog, that libmpv actually
|
||||||
|
* decodes the decrypted stream, that a cancelled download leaves a resumable
|
||||||
|
* `.part`, and that a resume position survives a restart.
|
||||||
|
*
|
||||||
|
* The link is an instrumentation argument rather than a constant or a file, so
|
||||||
|
* the secret never enters the repository:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* -Pandroid.testInstrumentationRunnerArguments.pstr.shareUrl=...
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Without it every test here is skipped by assumption, and
|
||||||
|
* `scripts/android-acceptance.sh` reports the matrix as incomplete.
|
||||||
|
*
|
||||||
|
* Read-only against the share by construction: nothing here uploads, renames or
|
||||||
|
* deletes anything remote. It writes only to this test's own temporary
|
||||||
|
* directories.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LiveShareTest {
|
||||||
|
@Test
|
||||||
|
fun theShareResolvesIntoAPlayableCatalog() {
|
||||||
|
val episodes = episodes()
|
||||||
|
assertTrue("the crawl found no playable file in the share", episodes.isNotEmpty())
|
||||||
|
// A file with no size cannot be streamed: the block layer needs the
|
||||||
|
// length to place block boundaries at all.
|
||||||
|
val sized = episodes.filter { (it.size ?: 0uL) > 0uL }
|
||||||
|
assertTrue("no episode in the share reported a size", sized.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun openingAnEpisodeYieldsASeekableRevision() {
|
||||||
|
val episode = playable()
|
||||||
|
val stream = runBlocking {
|
||||||
|
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assertTrue("the opened revision has no length", stream.size() > 0uL)
|
||||||
|
// Without a revision id the block cache cannot tell one revision of
|
||||||
|
// a file from another, and a stale block would be served as fresh.
|
||||||
|
assertTrue("the opened revision has no id", stream.revisionId().isNotBlank())
|
||||||
|
} finally {
|
||||||
|
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole pipeline in one assertion: Proton block storage, decryption,
|
||||||
|
* the Rust stream C ABI, the JNI adapter and libmpv's demuxer. If the
|
||||||
|
* position advances, real decrypted bytes reached the decoder.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun libmpvDecodesTheDecryptedStream() {
|
||||||
|
val episode = playable()
|
||||||
|
val texture = SurfaceTexture(0).apply { setDefaultBufferSize(1280, 720) }
|
||||||
|
val surface = Surface(texture)
|
||||||
|
val host = NativeMpvHost()
|
||||||
|
val key = "${episode.shareId}:${episode.linkId}"
|
||||||
|
try {
|
||||||
|
host.attachSurface(surface)
|
||||||
|
val stream = runBlocking {
|
||||||
|
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||||
|
}
|
||||||
|
runBlocking { host.play(key, stream.nativeHandle(), stream.size()) }
|
||||||
|
host.setMuted(true)
|
||||||
|
host.setPaused(false)
|
||||||
|
|
||||||
|
val playing = waitFor(PLAYBACK_TIMEOUT_MS) { host.state.value.position > 0.0 }
|
||||||
|
assertTrue(
|
||||||
|
"libmpv never advanced past zero: ${host.state.value}",
|
||||||
|
playing,
|
||||||
|
)
|
||||||
|
// One host serves every episode in turn, so a reading that does not
|
||||||
|
// name this one would resume the wrong file.
|
||||||
|
assertEquals(key, host.state.value.media)
|
||||||
|
assertTrue("libmpv reported no duration", host.state.value.duration > 0.0)
|
||||||
|
} finally {
|
||||||
|
host.stop()
|
||||||
|
host.detachSurface()
|
||||||
|
host.close()
|
||||||
|
surface.release()
|
||||||
|
texture.release()
|
||||||
|
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation is polled between whole Proton blocks precisely so the bytes
|
||||||
|
* already written stay usable. This checks the retained `.part` is counted
|
||||||
|
* as partial rather than as a finished file, and that resuming continues
|
||||||
|
* from it instead of starting over.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aCancelledDownloadLeavesAResumablePartFile() {
|
||||||
|
val episode = playable()
|
||||||
|
val firstRun = CancelAfter(bytes = RESUME_THRESHOLD_BYTES)
|
||||||
|
runCatching {
|
||||||
|
runBlocking {
|
||||||
|
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, firstRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assumeTrue(
|
||||||
|
"the episode finished before cancellation could be observed; it is too small for this case",
|
||||||
|
!firstRun.completed,
|
||||||
|
)
|
||||||
|
assertTrue("the download reported no progress at all", firstRun.observed > 0L)
|
||||||
|
|
||||||
|
val retained = engine.storageUsage()
|
||||||
|
assertTrue("a cancelled download retained no bytes", retained.partialBytes > 0uL)
|
||||||
|
assertEquals("a cancelled download counted as a finished file", 0uL, retained.offlineCount)
|
||||||
|
|
||||||
|
// Resuming must pick up where the .part ended rather than at zero.
|
||||||
|
val secondRun = CancelAfter(bytes = retained.partialBytes.toLong() + RESUME_THRESHOLD_BYTES)
|
||||||
|
runCatching {
|
||||||
|
runBlocking {
|
||||||
|
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, secondRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
"resuming restarted from zero: first reading was ${secondRun.firstReading}, " +
|
||||||
|
"but ${retained.partialBytes} bytes were already retained",
|
||||||
|
secondRun.firstReading >= retained.partialBytes.toLong(),
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking { engine.removeOfflineEpisode(episode.shareId, episode.linkId) }
|
||||||
|
assertEquals(
|
||||||
|
"removing the episode left partial bytes behind",
|
||||||
|
0uL,
|
||||||
|
engine.storageUsage().partialBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the viewer sees as "continue watching" after the app is killed. */
|
||||||
|
@Test
|
||||||
|
fun aResumePositionSurvivesReopeningTheCatalog() {
|
||||||
|
val episode = playable()
|
||||||
|
engine.saveWatchState(episode.shareId, episode.linkId, RESUME_POSITION, null, false)
|
||||||
|
|
||||||
|
val reopened = AndroidEngine(paths, secrets)
|
||||||
|
val state = reopened.watchState(episode.shareId, episode.linkId)
|
||||||
|
assertNotNull("the resume position did not survive a reopen", state)
|
||||||
|
assertEquals(RESUME_POSITION, state!!.positionSecs, 0.001)
|
||||||
|
assertEquals(false, state.watched)
|
||||||
|
|
||||||
|
// The catalog view is what the UI reads, so it has to agree.
|
||||||
|
val listed = reopened.library(null)
|
||||||
|
.flatMap { it.seasons }
|
||||||
|
.flatMap { it.episodes }
|
||||||
|
.first { it.linkId == episode.linkId }
|
||||||
|
assertEquals(RESUME_POSITION, listed.resumeAt ?: -1.0, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels once the download has written enough to prove a resume. */
|
||||||
|
private class CancelAfter(private val bytes: Long) : DownloadObserver {
|
||||||
|
private val cancelled = AtomicBoolean(false)
|
||||||
|
private val progress = AtomicLong(0)
|
||||||
|
private val first = AtomicLong(-1)
|
||||||
|
|
||||||
|
val observed: Long get() = progress.get()
|
||||||
|
val firstReading: Long get() = first.get()
|
||||||
|
val completed: Boolean get() = !cancelled.get()
|
||||||
|
|
||||||
|
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||||
|
first.compareAndSet(-1, downloaded.toLong())
|
||||||
|
progress.set(downloaded.toLong())
|
||||||
|
if (downloaded.toLong() >= bytes) cancelled.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCancelled(): Boolean = cancelled.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episodes(): List<EpisodeRecord> =
|
||||||
|
engine.library(null).flatMap { it.seasons }.flatMap { it.episodes }
|
||||||
|
|
||||||
|
private fun playable(): EpisodeRecord =
|
||||||
|
episodes().firstOrNull { (it.size ?: 0uL) > 0uL }
|
||||||
|
?: throw AssertionError("the share has no sized file to play")
|
||||||
|
|
||||||
|
private fun waitFor(timeoutMs: Long, condition: () -> Boolean): Boolean {
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (condition()) return true
|
||||||
|
Thread.sleep(200)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stands in for Keystore, so a bridge failure cannot be blamed on crypto. */
|
||||||
|
private class MemorySecretStore : AndroidSecretStore {
|
||||||
|
private val entries = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
override fun set(key: String, value: String) {
|
||||||
|
entries[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(key: String): String? = entries[key]
|
||||||
|
|
||||||
|
override fun delete(key: String) {
|
||||||
|
entries.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SHARE_URL_ARGUMENT = "pstr.shareUrl"
|
||||||
|
private const val SHARE_PASSWORD_ARGUMENT = "pstr.sharePassword"
|
||||||
|
|
||||||
|
/** Long enough for a cold connection, a crawl and a first block read. */
|
||||||
|
private const val PLAYBACK_TIMEOUT_MS = 60_000L
|
||||||
|
|
||||||
|
/** Enough written bytes to prove a resume, small enough to stay quick. */
|
||||||
|
private const val RESUME_THRESHOLD_BYTES = 4L * 1024 * 1024
|
||||||
|
|
||||||
|
private const val RESUME_POSITION = 137.5
|
||||||
|
|
||||||
|
private lateinit var paths: AndroidPaths
|
||||||
|
private lateinit var secrets: AndroidSecretStore
|
||||||
|
private lateinit var engine: AndroidEngine
|
||||||
|
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun openTheShare() {
|
||||||
|
System.loadLibrary("pstr_android")
|
||||||
|
val arguments = InstrumentationRegistry.getArguments()
|
||||||
|
val url = arguments.getString(SHARE_URL_ARGUMENT)
|
||||||
|
assumeTrue(
|
||||||
|
"set -Pandroid.testInstrumentationRunnerArguments.$SHARE_URL_ARGUMENT to run the live cases",
|
||||||
|
!url.isNullOrBlank(),
|
||||||
|
)
|
||||||
|
val password = arguments.getString(SHARE_PASSWORD_ARGUMENT)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val root = File.createTempFile("pstr-live", "").let {
|
||||||
|
it.delete()
|
||||||
|
it.mkdirs()
|
||||||
|
it
|
||||||
|
}
|
||||||
|
paths = AndroidPaths(
|
||||||
|
File(root, "config").absolutePath,
|
||||||
|
File(root, "data").absolutePath,
|
||||||
|
File(root, "cache").absolutePath,
|
||||||
|
)
|
||||||
|
secrets = MemorySecretStore()
|
||||||
|
engine = AndroidEngine(paths, secrets)
|
||||||
|
engine.addShare("acceptance", url!!, password)
|
||||||
|
runBlocking { engine.crawl(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.security.KeyStore
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import uniffi.pstr_android.BridgeException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on a device because Android Keystore has no host implementation — which
|
||||||
|
* is why nothing pinned this before. The share fragment and any custom link
|
||||||
|
* password are the only user secrets the app holds, and losing the key makes
|
||||||
|
* every share unopenable, so the failure modes matter as much as the happy path.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class KeystoreSecretStoreTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var store: KeystoreSecretStore
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
clearPreferences()
|
||||||
|
store = KeystoreSecretStore(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
clearPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSecretSurvivesARoundTrip() {
|
||||||
|
store.set("share-1", "correct horse battery staple")
|
||||||
|
assertEquals("correct horse battery staple", store.get("share-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAbsentSecretIsNullRatherThanEmpty() {
|
||||||
|
assertNull(store.get("never-written"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesTheSecret() {
|
||||||
|
store.set("share-1", "value")
|
||||||
|
store.delete("share-1")
|
||||||
|
assertNull(store.get("share-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aUnicodeSecretIsNotMangled() {
|
||||||
|
val value = "パスワード・🎬・ü"
|
||||||
|
store.set("share-1", value)
|
||||||
|
assertEquals(value, store.get("share-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GCM is catastrophic under IV reuse: two secrets encrypted with the same
|
||||||
|
* key and IV leak the XOR of their plaintexts. `Cipher.init(ENCRYPT_MODE)`
|
||||||
|
* is what generates a fresh one, so this pins that the code never pins it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun everyWriteUsesAFreshInitialisationVector() {
|
||||||
|
store.set("share-1", "same plaintext")
|
||||||
|
val first = rawPayload("share-1")
|
||||||
|
store.set("share-1", "same plaintext")
|
||||||
|
val second = rawPayload("share-1")
|
||||||
|
|
||||||
|
val firstIv = first.copyOfRange(0, IV_SIZE)
|
||||||
|
val secondIv = second.copyOfRange(0, IV_SIZE)
|
||||||
|
assertNotEquals(Base64.encodeToString(firstIv, Base64.NO_WRAP), Base64.encodeToString(secondIv, Base64.NO_WRAP))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theStoredFormIsNotThePlaintext() {
|
||||||
|
store.set("share-1", "correct horse battery staple")
|
||||||
|
val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.getString("share-1", null)
|
||||||
|
assertTrue(stored != null && !stored.contains("correct horse"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tampered payload must not decrypt, and must fail as a [BridgeException].
|
||||||
|
* An `AEADBadTagException` crossing the FFI has no mapping on the Rust side,
|
||||||
|
* and an unmapped callback exception panics the calling thread (B18).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aTamperedPayloadFailsToDecrypt() {
|
||||||
|
store.set("share-1", "value")
|
||||||
|
val payload = rawPayload("share-1")
|
||||||
|
payload[payload.size - 1] = (payload[payload.size - 1].toInt() xor 0x01).toByte()
|
||||||
|
writeRawPayload("share-1", payload)
|
||||||
|
|
||||||
|
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTruncatedPayloadIsRejectedBeforeDecryption() {
|
||||||
|
store.set("share-1", "value")
|
||||||
|
writeRawPayload("share-1", rawPayload("share-1").copyOfRange(0, IV_SIZE))
|
||||||
|
|
||||||
|
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device-side half of B18: this is what a lockscreen change or a device
|
||||||
|
* restore does to the key. It must fail the read rather than the process,
|
||||||
|
* and the store must still accept a re-entered secret afterwards — which is
|
||||||
|
* what the shares screen's "Re-enter link" depends on.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aLostKeyFailsTheReadRatherThanTheProcess() {
|
||||||
|
store.set("share-1", "value")
|
||||||
|
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
|
||||||
|
|
||||||
|
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||||
|
|
||||||
|
// The store must still be usable for a re-entered secret.
|
||||||
|
store.set("share-1", "re-entered")
|
||||||
|
assertEquals("re-entered", store.get("share-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rawPayload(key: String): ByteArray {
|
||||||
|
val encoded = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.getString(key, null) ?: error("no payload for $key")
|
||||||
|
return Base64.decode(encoded, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeRawPayload(key: String, payload: ByteArray) {
|
||||||
|
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(key, Base64.encodeToString(payload, Base64.NO_WRAP))
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPreferences() {
|
||||||
|
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFERENCES = "proton_stream_secrets"
|
||||||
|
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
|
||||||
|
const val IV_SIZE = 12
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import uniffi.pstr_android.AndroidEngine
|
||||||
|
import uniffi.pstr_android.AndroidPaths
|
||||||
|
import uniffi.pstr_android.AndroidSecretStore
|
||||||
|
import uniffi.pstr_android.chapterPlan
|
||||||
|
import uniffi.pstr_android.skipOffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first thing that crosses the UniFFI boundary on a real device. It is
|
||||||
|
* deliberately offline: it proves the ABI, the generated bindings, the callback
|
||||||
|
* interface and the SQLite open all work, without needing a share.
|
||||||
|
*
|
||||||
|
* If this fails and the Rust workspace tests pass, the fault is in packaging —
|
||||||
|
* a missing ABI in `jniLibs`, a stale binding, or an R8 rule (run it with
|
||||||
|
* `ANDROID_TEST_BUILD_TYPE=release` to check the shipped configuration).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class UniFfiBoundaryTest {
|
||||||
|
/** Stands in for Keystore so a bridge failure cannot be blamed on crypto. */
|
||||||
|
private class MemorySecretStore : AndroidSecretStore {
|
||||||
|
val entries = ConcurrentHashMap<String, String>()
|
||||||
|
var reads = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun set(key: String, value: String) {
|
||||||
|
entries[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(key: String): String? {
|
||||||
|
reads += 1
|
||||||
|
return entries[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(key: String) {
|
||||||
|
entries.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun engine(secrets: AndroidSecretStore = MemorySecretStore()): AndroidEngine {
|
||||||
|
val root = File.createTempFile("pstr-bridge", "").let {
|
||||||
|
it.delete()
|
||||||
|
it.mkdirs()
|
||||||
|
it
|
||||||
|
}
|
||||||
|
return AndroidEngine(
|
||||||
|
AndroidPaths(
|
||||||
|
File(root, "config").absolutePath,
|
||||||
|
File(root, "data").absolutePath,
|
||||||
|
File(root, "cache").absolutePath,
|
||||||
|
),
|
||||||
|
secrets,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theEngineOpensAgainstEmptyDirectories() {
|
||||||
|
val engine = engine()
|
||||||
|
assertTrue(engine.shares().isEmpty())
|
||||||
|
assertTrue(engine.library(null).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun storageUsageReportsZeroOnAFreshInstall() {
|
||||||
|
val usage = engine().storageUsage()
|
||||||
|
assertNotNull(usage)
|
||||||
|
assertEquals(0UL, usage.offlineBytes)
|
||||||
|
assertEquals(0UL, usage.partialBytes)
|
||||||
|
assertEquals(0UL, usage.offlineCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metadataSettingsAreReadableAndDefaultToDisabled() {
|
||||||
|
// Enrichment is opt-in; a default of enabled would send titles to a
|
||||||
|
// third party before the viewer has agreed to it.
|
||||||
|
assertEquals(false, engine().metadataSettings().enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSecondEngineReopensTheSameCatalog() {
|
||||||
|
val secrets = MemorySecretStore()
|
||||||
|
engine(secrets).shares()
|
||||||
|
// Reopening is the process-restart path; a schema or lock fault shows up
|
||||||
|
// here rather than on a user's device.
|
||||||
|
assertTrue(engine(secrets).shares().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter classification is shared Rust that desktop already pins
|
||||||
|
* (`docs/BUGS.md` B12). This runs the same code through the bindings, so a
|
||||||
|
* lowering fault in the record types cannot pass unnoticed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun chapterPlanningCrossesTheBoundaryIntact() {
|
||||||
|
val plan = chapterPlan(emptyList(), 1560.0)
|
||||||
|
assertNotNull(plan)
|
||||||
|
assertNull(skipOffer(plan, 12.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@BeforeClass
|
||||||
|
@JvmStatic
|
||||||
|
fun loadLibrary() {
|
||||||
|
System.loadLibrary("pstr_android")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ add_library(pstr_mpv SHARED pstr_mpv.cpp)
|
|||||||
add_library(mpv SHARED IMPORTED)
|
add_library(mpv SHARED IMPORTED)
|
||||||
set_target_properties(mpv PROPERTIES
|
set_target_properties(mpv PROPERTIES
|
||||||
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
|
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
|
||||||
|
# libmpv links FFmpeg dynamically and does not re-export it, so registering the
|
||||||
|
# JavaVM that MediaCodec needs means linking libavcodec ourselves. It is already
|
||||||
|
# packaged in the same ABI directory as part of libmpv's dependency closure.
|
||||||
|
add_library(avcodec SHARED IMPORTED)
|
||||||
|
set_target_properties(avcodec PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libavcodec.so")
|
||||||
|
|
||||||
target_compile_features(pstr_mpv PRIVATE cxx_std_20)
|
target_compile_features(pstr_mpv PRIVATE cxx_std_20)
|
||||||
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
|
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
|
||||||
@@ -20,4 +26,4 @@ target_include_directories(pstr_mpv PRIVATE "${PSTR_MPV_ROOT}/include")
|
|||||||
# packaged into the same ABI directory in the APK.
|
# packaged into the same ABI directory in the APK.
|
||||||
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
|
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
|
||||||
target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
|
target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
|
||||||
target_link_libraries(pstr_mpv PRIVATE mpv android EGL log)
|
target_link_libraries(pstr_mpv PRIVATE mpv avcodec android EGL log)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "pstr_mpv.h"
|
#include "pstr_mpv.h"
|
||||||
|
|
||||||
#include <EGL/egl.h>
|
#include <EGL/egl.h>
|
||||||
|
#include <android/log.h>
|
||||||
#include <android/native_window.h>
|
#include <android/native_window.h>
|
||||||
#include <android/native_window_jni.h>
|
#include <android/native_window_jni.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
@@ -8,6 +9,10 @@
|
|||||||
#include <mpv/render_gl.h>
|
#include <mpv/render_gl.h>
|
||||||
#include <mpv/stream_cb.h>
|
#include <mpv/stream_cb.h>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavcodec/jni.h>
|
||||||
|
}
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
@@ -86,16 +91,32 @@ int64_t stream_size(void *opaque) noexcept {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mpv wants its request slot back — a seek, or a teardown.
|
||||||
|
///
|
||||||
|
/// The flag alone only stops the *next* read; the one already parked in a 4 MiB
|
||||||
|
/// block fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
|
||||||
|
/// out the whole fetch while joining the demuxer thread.
|
||||||
void stream_cancel(void *opaque) noexcept {
|
void stream_cancel(void *opaque) noexcept {
|
||||||
c_boundary([=] {
|
c_boundary([=] {
|
||||||
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
||||||
stream->cancelled.store(true, std::memory_order_relaxed);
|
stream->cancelled.store(true, std::memory_order_relaxed);
|
||||||
|
pstr_android_stream_cancel(stream->handle);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases the Rust stream behind the file mpv has finished with.
|
||||||
|
///
|
||||||
|
/// Ownership sits here rather than in Kotlin because this is the one moment
|
||||||
|
/// that is *defined* to be after mpv's last read: `loadfile` is asynchronous, so
|
||||||
|
/// a Kotlin-side release issued when the next load returns can land while the
|
||||||
|
/// outgoing demuxer is still reading — and, if the release names the handle the
|
||||||
|
/// registry has already swapped, takes the incoming episode's stream with it.
|
||||||
void stream_close(void *opaque) noexcept {
|
void stream_close(void *opaque) noexcept {
|
||||||
c_boundary([=] { delete static_cast<StreamCookie *>(opaque); });
|
c_boundary([=] {
|
||||||
|
std::unique_ptr<StreamCookie> cookie(static_cast<StreamCookie *>(opaque));
|
||||||
|
if (cookie) pstr_android_stream_release(cookie->handle);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
|
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
|
||||||
@@ -130,6 +151,29 @@ void *resolve_gl(void *, const char *name) noexcept {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the open file ended, mirroring `pstr_player::EndReason` so that the two
|
||||||
|
/// clients agree on what "finished" means. Only [Eof] is an episode the viewer
|
||||||
|
/// watched to the end; the rest are a stop, a shutdown or a failure, and none of
|
||||||
|
/// them may mark anything watched or advance to the next episode.
|
||||||
|
enum class EndReason : int {
|
||||||
|
None = 0,
|
||||||
|
Eof = 1,
|
||||||
|
Stopped = 2,
|
||||||
|
Quit = 3,
|
||||||
|
Failed = 4,
|
||||||
|
Other = 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
EndReason end_reason_of(int reason) {
|
||||||
|
switch (reason) {
|
||||||
|
case MPV_END_FILE_REASON_EOF: return EndReason::Eof;
|
||||||
|
case MPV_END_FILE_REASON_STOP: return EndReason::Stopped;
|
||||||
|
case MPV_END_FILE_REASON_QUIT: return EndReason::Quit;
|
||||||
|
case MPV_END_FILE_REASON_ERROR: return EndReason::Failed;
|
||||||
|
default: return EndReason::Other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct PlaybackState {
|
struct PlaybackState {
|
||||||
double position = 0.0;
|
double position = 0.0;
|
||||||
double duration = 0.0;
|
double duration = 0.0;
|
||||||
@@ -137,6 +181,30 @@ struct PlaybackState {
|
|||||||
bool paused = true;
|
bool paused = true;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
bool ended = false;
|
bool ended = false;
|
||||||
|
EndReason end_reason = EndReason::None;
|
||||||
|
/// mpv has run out of buffered data and stopped to refill. Distinct from
|
||||||
|
/// [paused], which is the viewer's doing: a frozen picture that nothing
|
||||||
|
/// explains is indistinguishable from a hung app.
|
||||||
|
bool buffering = false;
|
||||||
|
/// How full the demuxer cache is while [buffering], 0–100.
|
||||||
|
double cache_percent = 0.0;
|
||||||
|
/// Between a seek being issued and playback actually resuming. The seek bar
|
||||||
|
/// has moved but the picture has not.
|
||||||
|
bool seeking = false;
|
||||||
|
/// True between `loadfile` being issued and mpv acknowledging it with
|
||||||
|
/// `START_FILE`. Everything mpv reports in that window still describes the
|
||||||
|
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
|
||||||
|
/// and must not be attributed to the file being loaded.
|
||||||
|
bool loading = false;
|
||||||
|
/// The picture's display size, once mpv has decoded enough to know it.
|
||||||
|
/// Zero until then, and the aspect Picture-in-Picture is given.
|
||||||
|
double video_width = 0.0;
|
||||||
|
double video_height = 0.0;
|
||||||
|
/// Which loaded file this state describes. Bumped by every load, so a
|
||||||
|
/// reader can tell "episode two, second zero" from a leftover reading of
|
||||||
|
/// episode one — which is the difference between resuming an episode where
|
||||||
|
/// the viewer left it and resuming it where the *previous* one ended.
|
||||||
|
int64_t generation = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Player {
|
class Player {
|
||||||
@@ -148,7 +216,15 @@ class Player {
|
|||||||
option("vo", "libmpv");
|
option("vo", "libmpv");
|
||||||
option("force-window", "no");
|
option("force-window", "no");
|
||||||
option("video-timing-offset", "0");
|
option("video-timing-offset", "0");
|
||||||
option("hwdec", "auto-safe");
|
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
||||||
|
// an `ANativeWindow` the decoder is handed at init, and this player has
|
||||||
|
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
|
||||||
|
// and the SurfaceView — when there is one at all — attaches later than
|
||||||
|
// the load. `auto-safe` picks them anyway and every file opens with
|
||||||
|
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
||||||
|
// falling back. The copy variants decode in hardware and hand back
|
||||||
|
// ordinary frames, which is what this render path can actually use.
|
||||||
|
option("hwdec", "auto-copy-safe");
|
||||||
option("cache", "yes");
|
option("cache", "yes");
|
||||||
option("cache-secs", "30");
|
option("cache-secs", "30");
|
||||||
option("demuxer-readahead-secs", "30");
|
option("demuxer-readahead-secs", "30");
|
||||||
@@ -165,6 +241,13 @@ class Player {
|
|||||||
observe("pause", 3, MPV_FORMAT_FLAG);
|
observe("pause", 3, MPV_FORMAT_FLAG);
|
||||||
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
||||||
observe("mute", 5, MPV_FORMAT_FLAG);
|
observe("mute", 5, MPV_FORMAT_FLAG);
|
||||||
|
observe("video-params/dw", 6, MPV_FORMAT_INT64);
|
||||||
|
observe("video-params/dh", 7, MPV_FORMAT_INT64);
|
||||||
|
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
|
||||||
|
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
|
||||||
|
// Errors mpv reports about the file itself, rather than about the
|
||||||
|
// request that opened it. Without these a failed demux is silent.
|
||||||
|
mpv_request_log_messages(mpv_, "error");
|
||||||
events_ = std::thread([this] { event_loop(); });
|
events_ = std::thread([this] { event_loop(); });
|
||||||
renderer_ = std::thread([this] { render_loop(); });
|
renderer_ = std::thread([this] { render_loop(); });
|
||||||
}
|
}
|
||||||
@@ -213,23 +296,60 @@ class Player {
|
|||||||
bool load(uint64_t handle, double start, const std::string &audio,
|
bool load(uint64_t handle, double start, const std::string &audio,
|
||||||
const std::string &subtitle, bool subtitles) {
|
const std::string &subtitle, bool subtitles) {
|
||||||
if (!mpv_) return false;
|
if (!mpv_) return false;
|
||||||
{
|
// No wait for a render context. It is created with the render thread and
|
||||||
std::unique_lock lock(render_mutex_);
|
// needs no window, and a load that cannot get one plays audio anyway —
|
||||||
if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false;
|
// waiting for a surface here is what made background playback fail after
|
||||||
}
|
// a five-second stall.
|
||||||
if (!audio.empty()) set_string("alang", audio);
|
if (!audio.empty()) set_string("alang", audio);
|
||||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||||
set_string("sid", subtitles ? "auto" : "no");
|
set_string("sid", subtitles ? "auto" : "no");
|
||||||
pending_start_.store(std::max(0.0, start));
|
pending_start_.store(std::max(0.0, start));
|
||||||
|
// Retire the outgoing file's position, duration and end flag *before*
|
||||||
|
// the new one loads. mpv only republishes them once it has demuxed
|
||||||
|
// enough of the stream, and until then every reader would otherwise see
|
||||||
|
// the last episode's clock under the new episode's name.
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
const double volume = state_.volume;
|
||||||
|
const bool muted = state_.muted;
|
||||||
|
// `pause` is a property of the core, not of the file: mpv carries it
|
||||||
|
// across `loadfile` unchanged and therefore never re-publishes it.
|
||||||
|
// Resetting it to the struct's default would leave every reader
|
||||||
|
// believing a playing file is paused for as long as it is open, and
|
||||||
|
// every "play" they then send is a no-op against a core that never
|
||||||
|
// paused — which is a play/pause button that does nothing.
|
||||||
|
const bool paused = state_.paused;
|
||||||
|
const int64_t generation = state_.generation + 1;
|
||||||
|
state_ = PlaybackState{};
|
||||||
|
state_.volume = volume;
|
||||||
|
state_.muted = muted;
|
||||||
|
state_.paused = paused;
|
||||||
|
state_.generation = generation;
|
||||||
|
state_.loading = true;
|
||||||
|
error_.clear();
|
||||||
|
}
|
||||||
const std::string url = "pstr://" + std::to_string(handle);
|
const std::string url = "pstr://" + std::to_string(handle);
|
||||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||||
if (mpv_command(mpv_, command) < 0) return false;
|
if (mpv_command(mpv_, command) < 0) {
|
||||||
|
// Nothing was issued, so no START_FILE will arrive to clear this.
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.loading = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void pause(bool value) { set_flag("pause", value); }
|
/// Written through rather than left to the observer: the transport is drawn
|
||||||
|
/// from this state, and a tap that only shows its effect on mpv's next
|
||||||
|
/// property event reads as a button that missed.
|
||||||
|
void pause(bool value) {
|
||||||
|
set_flag("pause", value);
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.paused = value;
|
||||||
|
}
|
||||||
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
|
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
|
||||||
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
|
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
|
||||||
|
void speed(double value) { set_double("speed", std::clamp(value, 0.25, 4.0)); }
|
||||||
void mute(bool value) { set_flag("mute", value); }
|
void mute(bool value) { set_flag("mute", value); }
|
||||||
void select_track(bool audio, int64_t id) {
|
void select_track(bool audio, int64_t id) {
|
||||||
const char *property = audio ? "aid" : "sid";
|
const char *property = audio ? "aid" : "sid";
|
||||||
@@ -246,6 +366,12 @@ class Player {
|
|||||||
return state_;
|
return state_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pending error, if any, consumed by reading it.
|
||||||
|
std::string take_error() {
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
return std::exchange(error_, std::string());
|
||||||
|
}
|
||||||
|
|
||||||
std::string tracks_json() const {
|
std::string tracks_json() const {
|
||||||
if (!mpv_) return "[]";
|
if (!mpv_) return "[]";
|
||||||
int64_t count = 0;
|
int64_t count = 0;
|
||||||
@@ -274,22 +400,115 @@ class Player {
|
|||||||
return json + ']';
|
return json + ']';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file's chapters, read one sub-property at a time like the tracks.
|
||||||
|
/// What they *mean* is decided in Rust (`pstr_core::chapters`), so the
|
||||||
|
/// desktop and this client offer the same skips.
|
||||||
|
std::string chapters_json() const {
|
||||||
|
if (!mpv_) return "[]";
|
||||||
|
int64_t count = 0;
|
||||||
|
if (mpv_get_property(mpv_, "chapter-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
|
||||||
|
std::string json = "[";
|
||||||
|
for (int64_t i = 0; i < count; ++i) {
|
||||||
|
const std::string base = "chapter-list/" + std::to_string(i) + "/";
|
||||||
|
char *title_raw = nullptr;
|
||||||
|
double start = 0.0;
|
||||||
|
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||||
|
MpvString title(title_raw);
|
||||||
|
mpv_get_property(mpv_, (base + "time").c_str(), MPV_FORMAT_DOUBLE, &start);
|
||||||
|
if (i) json += ',';
|
||||||
|
json += "{\"index\":" + std::to_string(i) + ",\"title\":\"" + escape(title.get()) +
|
||||||
|
"\",\"start\":" + std::to_string(start) + '}';
|
||||||
|
}
|
||||||
|
return json + ']';
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct MpvFree {
|
struct MpvFree {
|
||||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||||
};
|
};
|
||||||
using MpvString = std::unique_ptr<char, MpvFree>;
|
using MpvString = std::unique_ptr<char, MpvFree>;
|
||||||
|
|
||||||
|
/// JSON-escape `text`, and re-encode it as Modified UTF-8.
|
||||||
|
///
|
||||||
|
/// The result of this ends up in `NewStringUTF`, which does not take UTF-8:
|
||||||
|
/// it takes Modified UTF-8, where a character outside the basic plane is a
|
||||||
|
/// surrogate *pair* of three-byte sequences rather than one four-byte
|
||||||
|
/// sequence. Handing it real UTF-8 renders an emoji in a track title as
|
||||||
|
/// mojibake, and aborts the process under CheckJNI.
|
||||||
|
///
|
||||||
|
/// Anything that is not well-formed UTF-8 is dropped rather than passed
|
||||||
|
/// through, for the same reason: a truncated sequence is what CheckJNI
|
||||||
|
/// rejects, and a track title is not worth a crash.
|
||||||
static std::string escape(const char *text) {
|
static std::string escape(const char *text) {
|
||||||
std::string out;
|
std::string out;
|
||||||
if (!text) return out;
|
if (!text) return out;
|
||||||
for (const unsigned char c : std::string_view(text)) {
|
const std::string_view input(text);
|
||||||
if (c == '"' || c == '\\') out += '\\';
|
for (size_t i = 0; i < input.size();) {
|
||||||
if (c >= 0x20) out += static_cast<char>(c);
|
const auto lead = static_cast<unsigned char>(input[i]);
|
||||||
|
size_t length = 0;
|
||||||
|
uint32_t code = 0;
|
||||||
|
if (lead < 0x80) {
|
||||||
|
length = 1;
|
||||||
|
code = lead;
|
||||||
|
} else if ((lead & 0xE0) == 0xC0) {
|
||||||
|
length = 2;
|
||||||
|
code = lead & 0x1Fu;
|
||||||
|
} else if ((lead & 0xF0) == 0xE0) {
|
||||||
|
length = 3;
|
||||||
|
code = lead & 0x0Fu;
|
||||||
|
} else if ((lead & 0xF8) == 0xF0) {
|
||||||
|
length = 4;
|
||||||
|
code = lead & 0x07u;
|
||||||
|
} else {
|
||||||
|
++i; // A stray continuation or invalid lead byte.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i + length > input.size()) break;
|
||||||
|
bool valid = true;
|
||||||
|
for (size_t k = 1; k < length; ++k) {
|
||||||
|
const auto next = static_cast<unsigned char>(input[i + k]);
|
||||||
|
if ((next & 0xC0) != 0x80) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
code = (code << 6) | (next & 0x3Fu);
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += length;
|
||||||
|
|
||||||
|
if (code < 0x20) continue; // Control characters, including NUL.
|
||||||
|
if (code == '"' || code == '\\') {
|
||||||
|
out += '\\';
|
||||||
|
out += static_cast<char>(code);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code < 0x80) {
|
||||||
|
out += static_cast<char>(code);
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
out += static_cast<char>(0xC0 | (code >> 6));
|
||||||
|
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||||
|
} else if (code < 0x10000) {
|
||||||
|
append_three(out, code);
|
||||||
|
} else {
|
||||||
|
// The surrogate pair Modified UTF-8 wants, each half written as
|
||||||
|
// its own three-byte sequence.
|
||||||
|
const uint32_t rest = code - 0x10000;
|
||||||
|
append_three(out, 0xD800 + (rest >> 10));
|
||||||
|
append_three(out, 0xDC00 + (rest & 0x3FF));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void append_three(std::string &out, uint32_t code) {
|
||||||
|
out += static_cast<char>(0xE0 | (code >> 12));
|
||||||
|
out += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||||
|
}
|
||||||
|
|
||||||
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
|
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
|
||||||
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
|
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
|
||||||
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
|
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
|
||||||
@@ -306,25 +525,72 @@ class Player {
|
|||||||
if (start > 0.0) set_double("time-pos", start);
|
if (start > 0.0) set_double("time-pos", start);
|
||||||
}
|
}
|
||||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||||
|
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
|
// The end of the file `loadfile` is replacing, not of the one it
|
||||||
|
// is loading. Reporting it would hand every reader an episode
|
||||||
|
// that ended before it started.
|
||||||
|
if (state_.loading) continue;
|
||||||
state_.ended = true;
|
state_.ended = true;
|
||||||
|
state_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
|
||||||
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.loading = false;
|
||||||
state_.ended = false;
|
state_.ended = false;
|
||||||
|
state_.end_reason = EndReason::None;
|
||||||
|
} else if (event->event_id == MPV_EVENT_SEEK) {
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.seeking = true;
|
||||||
|
} else if (event->event_id == MPV_EVENT_PLAYBACK_RESTART) {
|
||||||
|
// The seek is *visibly* done here, not when it was issued.
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.seeking = false;
|
||||||
|
} else if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
|
||||||
|
record_log(*static_cast<mpv_event_log_message *>(event->data));
|
||||||
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
|
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
|
||||||
update_property(*static_cast<mpv_event_property *>(event->data));
|
update_property(*static_cast<mpv_event_property *>(event->data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the first error of the open file, not the last.
|
||||||
|
///
|
||||||
|
/// A failure cascades — one unreadable header produces a dozen follow-on
|
||||||
|
/// complaints — and the first line is the one that names the cause. Cleared
|
||||||
|
/// by [take_error], which is how the UI consumes it.
|
||||||
|
///
|
||||||
|
/// Only mpv's own errors. Everything libavcodec and libavformat complain
|
||||||
|
/// about arrives under an `ffmpeg/…` prefix, and mpv is the thing that
|
||||||
|
/// decides whether any of it mattered: a decoder that refuses to
|
||||||
|
/// initialise is an error there and a fallback here, so surfacing those
|
||||||
|
/// puts a dialog over a file that goes on to play perfectly. When one of
|
||||||
|
/// them really is fatal mpv says so itself, under a prefix of its own.
|
||||||
|
void record_log(const mpv_event_log_message &message) {
|
||||||
|
if (!message.text) return;
|
||||||
|
if (message.prefix && !std::strncmp(message.prefix, "ffmpeg", 6)) return;
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
if (!error_.empty()) return;
|
||||||
|
error_.assign(message.text);
|
||||||
|
while (!error_.empty() && (error_.back() == '\n' || error_.back() == '\r')) error_.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
void update_property(const mpv_event_property &property) {
|
void update_property(const mpv_event_property &property) {
|
||||||
if (!property.data) return;
|
if (!property.data) return;
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
|
// `volume`, `mute` and `pause` belong to the core and carry across a
|
||||||
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
|
// load; the rest describe the open file, and a reading of them that
|
||||||
else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
|
// arrives mid-load is the outgoing episode's clock under the incoming
|
||||||
|
// episode's name.
|
||||||
|
if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
|
||||||
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data);
|
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data);
|
||||||
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data);
|
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data);
|
||||||
|
else if (state_.loading) return;
|
||||||
|
else if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "video-params/dw")) state_.video_width = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
|
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
|
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
|
||||||
|
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void render_update(void *opaque) noexcept {
|
static void render_update(void *opaque) noexcept {
|
||||||
@@ -336,6 +602,15 @@ class Player {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owns EGL and the mpv render context for the life of the player.
|
||||||
|
///
|
||||||
|
/// Both are set up here rather than on the first surface attach, and neither
|
||||||
|
/// depends on there being a window: a pbuffer is enough to make a context
|
||||||
|
/// current, and `vo_libmpv` only needs *some* consumer. That is what makes
|
||||||
|
/// audio playback with no surface — the screen off, the app backgrounded —
|
||||||
|
/// structurally possible; before this, `load` waited five seconds for a
|
||||||
|
/// render context that only a visible SurfaceView could ever create, and
|
||||||
|
/// then failed.
|
||||||
void render_loop() {
|
void render_loop() {
|
||||||
EGLDisplay display = EGL_NO_DISPLAY;
|
EGLDisplay display = EGL_NO_DISPLAY;
|
||||||
EGLContext context = EGL_NO_CONTEXT;
|
EGLContext context = EGL_NO_CONTEXT;
|
||||||
@@ -343,6 +618,7 @@ class Player {
|
|||||||
EGLSurface pbuffer = EGL_NO_SURFACE;
|
EGLSurface pbuffer = EGL_NO_SURFACE;
|
||||||
EGLConfig config = nullptr;
|
EGLConfig config = nullptr;
|
||||||
ANativeWindow *window = nullptr;
|
ANativeWindow *window = nullptr;
|
||||||
|
if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
|
||||||
while (running_.load()) {
|
while (running_.load()) {
|
||||||
std::unique_lock lock(render_mutex_);
|
std::unique_lock lock(render_mutex_);
|
||||||
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
||||||
@@ -353,18 +629,31 @@ class Player {
|
|||||||
if (window) ANativeWindow_release(window);
|
if (window) ANativeWindow_release(window);
|
||||||
window = pending_window_;
|
window = pending_window_;
|
||||||
pending_window_ = nullptr;
|
pending_window_ = nullptr;
|
||||||
if (window && display == EGL_NO_DISPLAY && !initialize_egl(display, context, config, pbuffer)) {
|
if (window && display == EGL_NO_DISPLAY) {
|
||||||
|
// EGL failed at startup and playback is audio-only. Keep the
|
||||||
|
// window rather than the surface: nothing here can retry.
|
||||||
ANativeWindow_release(window);
|
ANativeWindow_release(window);
|
||||||
window = nullptr;
|
window = nullptr;
|
||||||
}
|
}
|
||||||
if (window) {
|
if (window) {
|
||||||
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
||||||
eglMakeCurrent(display, surface, surface, context);
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
initialize_renderer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bool draw = frame_ready_.exchange(false);
|
const bool draw = frame_ready_.exchange(false);
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
|
// With no window there is still a frame to consume. `vo_libmpv`
|
||||||
|
// expects its host to keep draining, and a video thread with no
|
||||||
|
// consumer stalls — which is why video came back out of sync with
|
||||||
|
// audio after the screen had been off.
|
||||||
|
if (draw && surface == EGL_NO_SURFACE && render_) {
|
||||||
|
eglMakeCurrent(display, pbuffer, pbuffer, context);
|
||||||
|
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
||||||
|
int skip = 1;
|
||||||
|
mpv_render_param params[] = {{MPV_RENDER_PARAM_SKIP_RENDERING, &skip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
|
||||||
|
mpv_render_context_render(render_, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (draw && surface != EGL_NO_SURFACE && render_) {
|
if (draw && surface != EGL_NO_SURFACE && render_) {
|
||||||
eglMakeCurrent(display, surface, surface, context);
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
||||||
@@ -394,18 +683,40 @@ class Player {
|
|||||||
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
|
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All of EGL, or none of it.
|
||||||
|
///
|
||||||
|
/// Leaving a display initialised behind a failed context is what made a
|
||||||
|
/// partial failure permanent: the retry guard is "is there a display", so a
|
||||||
|
/// half-built state reads as success forever and every later attach uses an
|
||||||
|
/// uninitialised config and `EGL_NO_CONTEXT`. Unwinding here keeps the guard
|
||||||
|
/// honest, and `eglGetError` says which step failed instead of leaving a
|
||||||
|
/// black screen with nothing in the log.
|
||||||
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
|
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
|
||||||
|
const auto fail = [&](const char *step) {
|
||||||
|
__android_log_print(ANDROID_LOG_ERROR, "pstr-mpv", "%s failed: 0x%04x", step, eglGetError());
|
||||||
|
if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
|
||||||
|
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||||
|
if (display != EGL_NO_DISPLAY) eglTerminate(display);
|
||||||
|
display = EGL_NO_DISPLAY;
|
||||||
|
context = EGL_NO_CONTEXT;
|
||||||
|
pbuffer = EGL_NO_SURFACE;
|
||||||
|
config = nullptr;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||||
if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) return false;
|
if (display == EGL_NO_DISPLAY) return fail("eglGetDisplay");
|
||||||
|
if (!eglInitialize(display, nullptr, nullptr)) return fail("eglInitialize");
|
||||||
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
|
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
|
||||||
EGLint count = 0;
|
EGLint count = 0;
|
||||||
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
|
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return fail("eglChooseConfig");
|
||||||
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
||||||
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
||||||
if (context == EGL_NO_CONTEXT) return false;
|
if (context == EGL_NO_CONTEXT) return fail("eglCreateContext");
|
||||||
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
||||||
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
||||||
return pbuffer != EGL_NO_SURFACE && eglMakeCurrent(display, pbuffer, pbuffer, context);
|
if (pbuffer == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface");
|
||||||
|
if (!eglMakeCurrent(display, pbuffer, pbuffer, context)) return fail("eglMakeCurrent");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void initialize_renderer() {
|
void initialize_renderer() {
|
||||||
@@ -426,6 +737,8 @@ class Player {
|
|||||||
std::thread renderer_;
|
std::thread renderer_;
|
||||||
mutable std::mutex state_mutex_;
|
mutable std::mutex state_mutex_;
|
||||||
PlaybackState state_;
|
PlaybackState state_;
|
||||||
|
/// The first error mpv logged about the open file, until it is consumed.
|
||||||
|
std::string error_;
|
||||||
std::mutex render_mutex_;
|
std::mutex render_mutex_;
|
||||||
std::condition_variable render_cv_;
|
std::condition_variable render_cv_;
|
||||||
ANativeWindow *pending_window_ = nullptr;
|
ANativeWindow *pending_window_ = nullptr;
|
||||||
@@ -465,13 +778,33 @@ bool utf8(JNIEnv *env, jstring value, std::string &result) {
|
|||||||
void reject_null_surface(JNIEnv *env) noexcept {
|
void reject_null_surface(JNIEnv *env) noexcept {
|
||||||
c_boundary([=] {
|
c_boundary([=] {
|
||||||
jclass type = env->FindClass("java/lang/IllegalArgumentException");
|
jclass type = env->FindClass("java/lang/IllegalArgumentException");
|
||||||
if (type) env->ThrowNew(type, "surface must not be null");
|
if (!type) return;
|
||||||
|
env->ThrowNew(type, "surface must not be null");
|
||||||
|
// FindClass hands back a local reference, and this is called from a
|
||||||
|
// long-lived attached thread where the frame is not popped for us.
|
||||||
|
env->DeleteLocalRef(type);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jobject) noexcept {
|
/// FFmpeg's MediaCodec decoders call into Android's Java API, and they can only
|
||||||
|
/// reach it through a JavaVM handed to them explicitly — there is no way for
|
||||||
|
/// them to discover one. Without this, every hardware decoder fails to open
|
||||||
|
/// with "hevc_mediacodec: No Java virtual machine has been registered" and
|
||||||
|
/// playback falls back to software decoding, which on a phone means a hot
|
||||||
|
/// device and dropped frames on anything above 1080p.
|
||||||
|
///
|
||||||
|
/// mpv-android's own JNI layer does this in its `create` entry point; this
|
||||||
|
/// library replaces that layer, so the registration has to happen here. The
|
||||||
|
/// loader calls this once per process before any native method, which is
|
||||||
|
/// earlier than any decoder can run.
|
||||||
|
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
|
||||||
|
av_jni_set_java_vm(vm, nullptr);
|
||||||
|
return JNI_VERSION_1_6;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept {
|
||||||
return c_boundary<jlong>(0, [] {
|
return c_boundary<jlong>(0, [] {
|
||||||
auto player = std::make_unique<Player>();
|
auto player = std::make_unique<Player>();
|
||||||
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
|
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
|
||||||
@@ -513,6 +846,9 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
|
|||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
|
c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSpeed(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||||
|
c_boundary([=] { if (auto *p = from(h)) p->speed(value); });
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
|
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
|
||||||
}
|
}
|
||||||
@@ -527,19 +863,38 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
|||||||
const auto state = from(h) ? from(h)->state() : PlaybackState{};
|
const auto state = from(h) ? from(h)->state() : PlaybackState{};
|
||||||
const jdouble values[] = {state.position, state.duration, state.volume,
|
const jdouble values[] = {state.position, state.duration, state.volume,
|
||||||
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
||||||
state.ended ? 1.0 : 0.0};
|
state.ended ? 1.0 : 0.0,
|
||||||
jdoubleArray result = env->NewDoubleArray(6);
|
static_cast<jdouble>(state.generation),
|
||||||
|
state.video_width, state.video_height,
|
||||||
|
static_cast<jdouble>(state.end_reason),
|
||||||
|
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||||
|
state.seeking ? 1.0 : 0.0};
|
||||||
|
constexpr jsize kFields = 13;
|
||||||
|
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
env->SetDoubleArrayRegion(result, 0, 6, values);
|
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||||
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTakeError(JNIEnv *env, jobject, jlong h) noexcept {
|
||||||
|
return c_boundary<jstring>(nullptr, [=]() -> jstring {
|
||||||
|
auto *player = from(h);
|
||||||
|
if (!player) return nullptr;
|
||||||
|
const std::string error = player->take_error();
|
||||||
|
return error.empty() ? nullptr : string(env, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
|
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
|
||||||
return c_boundary<jstring>(nullptr, [=] {
|
return c_boundary<jstring>(nullptr, [=] {
|
||||||
return string(env, from(h) ? from(h)->tracks_json() : "[]");
|
return string(env, from(h) ? from(h)->tracks_json() : "[]");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeChapters(JNIEnv *env, jobject, jlong h) noexcept {
|
||||||
|
return c_boundary<jstring>(nullptr, [=] {
|
||||||
|
return string(env, from(h) ? from(h)->chapters_json() : "[]");
|
||||||
|
});
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
|
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,9 @@ extern "C" {
|
|||||||
#endif
|
#endif
|
||||||
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
|
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
|
||||||
int64_t pstr_android_stream_size(uint64_t handle);
|
int64_t pstr_android_stream_size(uint64_t handle);
|
||||||
|
/// Interrupts an in-flight `pstr_android_stream_read`. A cancel with no reader
|
||||||
|
/// waiting is dropped, so this is safe to call speculatively.
|
||||||
|
void pstr_android_stream_cancel(uint64_t handle);
|
||||||
void pstr_android_stream_release(uint64_t handle);
|
void pstr_android_stream_release(uint64_t handle);
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,189 @@
|
|||||||
package io.narl.protonstream
|
package io.narl.protonstream
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.app.PendingIntent
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Build
|
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
|
import android.app.RemoteAction
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import io.narl.protonstream.ui.ProtonStreamApp
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.playback.MpvPlaybackState
|
||||||
import io.narl.protonstream.playback.NativeMpvHost
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlaybackService
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.ui.ProtonStreamApp
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
|
private val inPictureInPicture = mutableStateOf(false)
|
||||||
|
/**
|
||||||
|
* Whether a bind is outstanding, which is what has to be unbound.
|
||||||
|
*
|
||||||
|
* Set when `bindService` is called rather than when the connection arrives:
|
||||||
|
* an activity destroyed while the bind is still pending owes the system an
|
||||||
|
* `unbindService` just the same, and `onServiceConnected` may never run.
|
||||||
|
*/
|
||||||
private var bound = false
|
private var bound = false
|
||||||
private val playbackConnection = object : ServiceConnection {
|
private val playbackConnection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||||
bound = true
|
|
||||||
}
|
}
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
playerHost.value = null
|
playerHost.value = null
|
||||||
bound = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transport behind the buttons Android draws over the PiP window.
|
||||||
|
*
|
||||||
|
* A broadcast rather than a service intent: PiP is one of the states in
|
||||||
|
* which a background service start is refused, and the receiver is
|
||||||
|
* registered for as long as the activity that owns the window exists.
|
||||||
|
*/
|
||||||
|
private val pictureInPictureControls = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val host = playerHost.value ?: return
|
||||||
|
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
||||||
|
CONTROL_PLAY -> host.setPaused(false)
|
||||||
|
CONTROL_PAUSE -> host.setPaused(true)
|
||||||
|
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
||||||
|
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val notificationPermission = registerForActivityResult(
|
private val notificationPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// Before anything draws. The palette decides the window's own
|
||||||
|
// background and which way round the system bars' icons go, and both
|
||||||
|
// are wrong for a frame if they are set from a composition instead.
|
||||||
|
// Infallible by construction — an unreadable theme file resolves to the
|
||||||
|
// shipped default rather than throwing.
|
||||||
|
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE)
|
// Re-applied on every change, not just at startup: picking Latte from
|
||||||
|
// the settings page has to turn the status bar's icons dark in the same
|
||||||
|
// frame the page behind them turns light.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
|
||||||
|
}
|
||||||
|
bound = bindService(
|
||||||
|
Intent(this, PlaybackService::class.java),
|
||||||
|
playbackConnection,
|
||||||
|
Context.BIND_AUTO_CREATE,
|
||||||
|
)
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
pictureInPictureControls,
|
||||||
|
IntentFilter(ACTION_PIP_CONTROL),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
|
// Auto-enter has to be armed *before* the gesture that would use it:
|
||||||
|
// building the params inside onUserLeaveHint is too late for the swipe
|
||||||
|
// home that gesture navigation sends, which never calls it. Re-register
|
||||||
|
// whenever the picture's shape or the transport's does.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
snapshotFlow { playerHost.value }
|
||||||
|
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
||||||
|
.map { state -> PictureInPictureShape.of(state) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(playerHost.value)
|
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parts of the window Compose does not reach: the background behind the
|
||||||
|
* whole activity, and the polarity of the system bars' icons.
|
||||||
|
*
|
||||||
|
* The background matters for the frames before and between compositions —
|
||||||
|
* the launch frame, and a rotation. It used to be a hex literal in
|
||||||
|
* `themes.xml`, which meant every cold start showed a colour from one
|
||||||
|
* flavour regardless of which one was chosen.
|
||||||
|
*
|
||||||
|
* The icons matter because the app ships a light flavour. Pinned dark, as
|
||||||
|
* they were, Latte drew white status-bar icons onto a near-white page.
|
||||||
|
*/
|
||||||
|
private fun dressWindow(palette: PaletteRecord) {
|
||||||
|
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = palette.light
|
||||||
|
isAppearanceLightNavigationBars = palette.light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
override fun onUserLeaveHint() {
|
||||||
super.onUserLeaveHint()
|
super.onUserLeaveHint()
|
||||||
|
// The backstop for launchers that still send this instead of arming
|
||||||
|
// auto-enter. Entering twice is not an error; not entering at all is.
|
||||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
||||||
enterPictureInPictureMode(
|
runCatching {
|
||||||
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
|
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||||
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
|
// The window is now a thumbnail with its own controls. Anything the
|
||||||
|
// player would draw over it — the transport, the title bar, the skip
|
||||||
|
// offer — is unreadable at that size and covers the picture.
|
||||||
|
inPictureInPicture.value = isInPictureInPictureMode
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
if (bound) unbindService(playbackConnection)
|
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||||
|
if (bound) {
|
||||||
|
unbindService(playbackConnection)
|
||||||
|
bound = false
|
||||||
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,4 +197,81 @@ class MainActivity : ComponentActivity() {
|
|||||||
playerHost.value?.stop()
|
playerHost.value?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
||||||
|
private data class PictureInPictureShape(
|
||||||
|
val active: Boolean,
|
||||||
|
val paused: Boolean,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
||||||
|
active = state != null && state.duration > 0.0 && !state.ended,
|
||||||
|
paused = state?.paused != false,
|
||||||
|
width = state?.videoWidth?.toInt() ?: 0,
|
||||||
|
height = state?.videoHeight?.toInt() ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
||||||
|
PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(aspectRatio(width, height))
|
||||||
|
.setAutoEnterEnabled(active)
|
||||||
|
.setSeamlessResizeEnabled(false)
|
||||||
|
// The player owns the whole window, so the picture's rectangle is
|
||||||
|
// the window's. Without it the transition into PiP cross-fades from
|
||||||
|
// a screenshot instead of shrinking the video that is already there.
|
||||||
|
.setSourceRectHint(windowBounds())
|
||||||
|
.setActions(
|
||||||
|
listOf(
|
||||||
|
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
||||||
|
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
||||||
|
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
||||||
|
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
||||||
|
|
||||||
|
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(this, icon),
|
||||||
|
label,
|
||||||
|
label,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
this,
|
||||||
|
requestCode,
|
||||||
|
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
||||||
|
const val EXTRA_CONTROL = "control"
|
||||||
|
const val CONTROL_PLAY = "play"
|
||||||
|
const val CONTROL_PAUSE = "pause"
|
||||||
|
const val CONTROL_NEXT = "next"
|
||||||
|
const val CONTROL_PREVIOUS = "previous"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
||||||
|
* and refuses the request outright rather than clamping it — so a
|
||||||
|
* 2.40:1 scope release would take PiP away entirely.
|
||||||
|
*/
|
||||||
|
fun aspectRatio(width: Int, height: Int): Rational {
|
||||||
|
if (width <= 0 || height <= 0) return Rational(16, 9)
|
||||||
|
val ratio = width.toDouble() / height
|
||||||
|
return when {
|
||||||
|
ratio > MAX_RATIO -> Rational(239, 100)
|
||||||
|
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
||||||
|
else -> Rational(width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val MAX_RATIO = 2.39
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,151 @@
|
|||||||
package io.narl.protonstream.download
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.StatFs
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
object DownloadCoordinator {
|
object DownloadCoordinator {
|
||||||
fun enqueue(context: Context, episode: EpisodeRecord) {
|
/**
|
||||||
enqueue(
|
* Where bulk queueing runs.
|
||||||
context,
|
*
|
||||||
RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label),
|
* Callers are click handlers, and "Download show" on a long series is a
|
||||||
ExistingWorkPolicy.KEEP,
|
* WorkManager enqueue per episode — each of which writes to its own
|
||||||
)
|
* database. On the main thread that is an ANR waiting for a long enough
|
||||||
}
|
* series.
|
||||||
|
*/
|
||||||
|
private val queueing = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
fun enqueue(context: Context, episode: EpisodeRecord) = enqueue(context, listOf(episode))
|
||||||
|
|
||||||
fun resume(context: Context, download: RetainedDownload) {
|
fun resume(context: Context, download: RetainedDownload) {
|
||||||
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
||||||
DownloadStateStore(context).put(
|
val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
|
||||||
download.copy(status = RetainedDownload.STATUS_QUEUED, error = null),
|
DownloadStateStore(context).put(queued)
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
workName(download.shareId, download.linkId),
|
||||||
|
policy,
|
||||||
|
request(queued, SettingsStore(context).wifiOnly),
|
||||||
)
|
)
|
||||||
val request = OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
|
}
|
||||||
|
|
||||||
|
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
|
||||||
|
val wanted = episodes.filterNot(EpisodeRecord::offline).toList()
|
||||||
|
if (wanted.isEmpty()) return
|
||||||
|
val application = context.applicationContext
|
||||||
|
queueing.launch {
|
||||||
|
val wifiOnly = SettingsStore(application).wifiOnly
|
||||||
|
val store = DownloadStateStore(application)
|
||||||
|
val manager = WorkManager.getInstance(application)
|
||||||
|
// Space is checked once for the whole request, against its total.
|
||||||
|
// Queueing a season that cannot fit and discovering it episode by
|
||||||
|
// episode at ENOSPC is how the app fills its own database's disk.
|
||||||
|
val room = roomFor(application, wanted.sumOf { it.size?.toLong() ?: 0L })
|
||||||
|
val downloads = wanted.map { episode ->
|
||||||
|
RetainedDownload(
|
||||||
|
episode.shareId,
|
||||||
|
episode.volumeId,
|
||||||
|
episode.linkId,
|
||||||
|
episode.label,
|
||||||
|
total = episode.size?.toLong() ?: 0L,
|
||||||
|
status = if (room) RetainedDownload.STATUS_QUEUED else RetainedDownload.STATUS_FAILED,
|
||||||
|
error = if (room) null else "Not enough free space",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
store.putAll(downloads)
|
||||||
|
if (!room) return@launch
|
||||||
|
downloads.forEach { download ->
|
||||||
|
manager.enqueueUniqueWork(
|
||||||
|
workName(download.shareId, download.linkId),
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
request(download, wifiOnly),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [bytes] plus working room will fit where offline files are kept.
|
||||||
|
*
|
||||||
|
* `setRequiresStorageNotLow` only holds work back at Android's own low-storage
|
||||||
|
* threshold, which a single episode can cross on its own. An unknown size —
|
||||||
|
* zero — is allowed through: refusing a download because the catalog has no
|
||||||
|
* size for it would be worse than the ENOSPC it is guarding against.
|
||||||
|
*/
|
||||||
|
private fun roomFor(context: Context, bytes: Long): Boolean {
|
||||||
|
if (bytes <= 0) return true
|
||||||
|
val available = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrNull() ?: return true
|
||||||
|
return available - bytes >= STORAGE_HEADROOM
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply the network constraint to work that is already queued.
|
||||||
|
*
|
||||||
|
* Constraints are baked in at enqueue time, so turning "Wi-Fi only" on after
|
||||||
|
* queueing a season did nothing to that season — it downloaded over cellular
|
||||||
|
* on the way out of the house, which is the one moment the setting exists
|
||||||
|
* for. Running work is left alone: WorkManager stops it on its own when the
|
||||||
|
* new constraint stops being met.
|
||||||
|
*/
|
||||||
|
fun applyNetworkPolicy(context: Context) {
|
||||||
|
val application = context.applicationContext
|
||||||
|
queueing.launch {
|
||||||
|
val wifiOnly = SettingsStore(application).wifiOnly
|
||||||
|
val manager = WorkManager.getInstance(application)
|
||||||
|
DownloadStateStore(application).records()
|
||||||
|
.filter { it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING }
|
||||||
|
.forEach { download ->
|
||||||
|
manager.enqueueUniqueWork(
|
||||||
|
workName(download.shareId, download.linkId),
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
request(download, wifiOnly),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause(context: Context, download: RetainedDownload) =
|
||||||
|
setStatus(context, download, RetainedDownload.STATUS_PAUSED)
|
||||||
|
|
||||||
|
fun cancel(context: Context, download: RetainedDownload) =
|
||||||
|
setStatus(context, download, RetainedDownload.STATUS_CANCELLED)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status write goes before the work is cancelled, and through `update`
|
||||||
|
* rather than `put`: the running worker is writing progress into the same
|
||||||
|
* record, and a whole-record `put` from a screen's stale copy would both
|
||||||
|
* lose that progress and race the worker's own read-modify-write.
|
||||||
|
*/
|
||||||
|
private fun setStatus(context: Context, download: RetainedDownload, status: String) {
|
||||||
|
DownloadStateStore(context).update(download.shareId, download.linkId) { stored ->
|
||||||
|
(stored ?: download).copy(status = status, error = null)
|
||||||
|
}
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(download: RetainedDownload, wifiOnly: Boolean): OneTimeWorkRequest =
|
||||||
|
OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
|
||||||
.setConstraints(
|
.setConstraints(
|
||||||
Constraints.Builder().setRequiredNetworkType(
|
Constraints.Builder()
|
||||||
requiredNetworkType(SettingsStore(context).wifiOnly),
|
.setRequiredNetworkType(requiredNetworkType(wifiOnly))
|
||||||
).build(),
|
// A download that fills the disk takes the app's own
|
||||||
|
// database down with it; the `.part` file makes waiting free.
|
||||||
|
.setRequiresStorageNotLow(true)
|
||||||
|
.build(),
|
||||||
)
|
)
|
||||||
.setInputData(
|
.setInputData(
|
||||||
workDataOf(
|
workDataOf(
|
||||||
@@ -45,26 +159,6 @@ object DownloadCoordinator {
|
|||||||
.addTag("$EPISODE_TAG${download.label}")
|
.addTag("$EPISODE_TAG${download.label}")
|
||||||
.addTag(shareTag(download.shareId))
|
.addTag(shareTag(download.shareId))
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
||||||
workName(download.shareId, download.linkId),
|
|
||||||
policy,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
|
|
||||||
episodes.filterNot(EpisodeRecord::offline).forEach { enqueue(context, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause(context: Context, download: RetainedDownload) {
|
|
||||||
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_PAUSED, error = null))
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(context: Context, download: RetainedDownload) {
|
|
||||||
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_CANCELLED))
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
|
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
|
||||||
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
||||||
@@ -72,6 +166,9 @@ object DownloadCoordinator {
|
|||||||
const val TAG = "offline-download"
|
const val TAG = "offline-download"
|
||||||
const val EPISODE_TAG = "offline-episode:"
|
const val EPISODE_TAG = "offline-episode:"
|
||||||
private const val SHARE_TAG = "offline-share:"
|
private const val SHARE_TAG = "offline-share:"
|
||||||
|
|
||||||
|
/** Free space to leave behind a download, for the catalog and the caches. */
|
||||||
|
private const val STORAGE_HEADROOM = 256L * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
|
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
|
||||||
|
|||||||
@@ -35,18 +35,59 @@ class DownloadStateStore(context: Context) {
|
|||||||
fun get(shareId: String, linkId: String): RetainedDownload? =
|
fun get(shareId: String, linkId: String): RetainedDownload? =
|
||||||
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
||||||
|
|
||||||
fun put(record: RetainedDownload) {
|
fun put(record: RetainedDownload) = synchronized(WRITES) {
|
||||||
preferences.edit().putString(record.key, encode(record)).commit()
|
edit { putString(record.key, encode(record)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(shareId: String, linkId: String) {
|
/**
|
||||||
preferences.edit().remove(key(shareId, linkId)).commit()
|
* Read-modify-write one record, excluding every other writer.
|
||||||
|
*
|
||||||
|
* The worker's progress update reads the stored record to decide whether the
|
||||||
|
* viewer has paused, then writes it back. `pause()` is another writer, and
|
||||||
|
* without this a `put` landing between those two halves puts `RUNNING` back
|
||||||
|
* over `PAUSED` — after which the worker's own cancellation handler sees a
|
||||||
|
* status that is not `PAUSED` and records `CANCELLED`. The pause is simply
|
||||||
|
* lost, which is the visible bug.
|
||||||
|
*
|
||||||
|
* Returning null from [change] leaves the record alone.
|
||||||
|
*/
|
||||||
|
fun update(shareId: String, linkId: String, change: (RetainedDownload?) -> RetainedDownload?) =
|
||||||
|
synchronized(WRITES) {
|
||||||
|
change(get(shareId, linkId))?.let { edit { putString(it.key, encode(it)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a whole queue in one go.
|
||||||
|
*
|
||||||
|
* Queueing a season is one call, not one per episode: `commit()` per record
|
||||||
|
* on a 200-episode series was 200 synchronous fsyncs, and the callers are
|
||||||
|
* click handlers.
|
||||||
|
*/
|
||||||
|
fun putAll(records: Collection<RetainedDownload>) = synchronized(WRITES) {
|
||||||
|
edit { records.forEach { putString(it.key, encode(it)) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeShare(shareId: String) {
|
fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
|
||||||
val edit = preferences.edit()
|
edit { remove(key(shareId, linkId)) }
|
||||||
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) }
|
}
|
||||||
edit.commit()
|
|
||||||
|
/** Forget every retained download. Callers cancel the work first. */
|
||||||
|
fun clear() = synchronized(WRITES) { edit { clear() } }
|
||||||
|
|
||||||
|
fun removeShare(shareId: String) = synchronized(WRITES) {
|
||||||
|
edit { records().filter { it.shareId == shareId }.forEach { remove(it.key) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `apply()`, not `commit()`.
|
||||||
|
*
|
||||||
|
* The in-memory map is updated before this returns, so a read that follows a
|
||||||
|
* write in the same process still sees it; only the disk write is deferred,
|
||||||
|
* and losing the newest progress reading to a kill is not worth an fsync on
|
||||||
|
* whichever thread happened to be writing.
|
||||||
|
*/
|
||||||
|
private inline fun edit(block: android.content.SharedPreferences.Editor.() -> Unit) {
|
||||||
|
preferences.edit().apply(block).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encode(record: RetainedDownload) = JSONObject()
|
private fun encode(record: RetainedDownload) = JSONObject()
|
||||||
@@ -78,5 +119,11 @@ class DownloadStateStore(context: Context) {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val NAME = "offline_download_state"
|
const val NAME = "offline_download_state"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One lock for the whole process. Instances of this class are created
|
||||||
|
* ad hoc from a `Context`, so the lock cannot live on one of them.
|
||||||
|
*/
|
||||||
|
val WRITES = Any()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
@@ -23,6 +24,7 @@ class OfflineDownloadWorker(
|
|||||||
parameters: WorkerParameters,
|
parameters: WorkerParameters,
|
||||||
) : CoroutineWorker(appContext, parameters) {
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo(0))
|
setForeground(createForegroundInfo(0))
|
||||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||||
@@ -35,20 +37,43 @@ class OfflineDownloadWorker(
|
|||||||
store.put(retained)
|
store.put(retained)
|
||||||
|
|
||||||
val observer = object : DownloadObserver {
|
val observer = object : DownloadObserver {
|
||||||
|
private var reportedAt = 0L
|
||||||
|
private var reportedPercent = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||||
|
* link, each one a WorkManager database write, a notification post
|
||||||
|
* and a preferences write. Nothing the viewer can see changes that
|
||||||
|
* often, so a report is only made when the percentage moves or a
|
||||||
|
* second has passed. The final one is never suppressed.
|
||||||
|
*/
|
||||||
override fun onProgress(downloaded: ULong, total: ULong) {
|
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val complete = total > 0UL && downloaded >= total
|
||||||
|
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reportedAt = now
|
||||||
|
reportedPercent = percent
|
||||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||||
setForegroundAsync(createForegroundInfo(percent))
|
setForegroundAsync(createForegroundInfo(percent))
|
||||||
val requested = store.get(shareId, linkId)
|
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||||
val requestedStatus = requested?.status
|
// this read-modify-write used to put RUNNING back over the
|
||||||
retained = (requested ?: retained).copy(
|
// viewer's PAUSED and then report the download as cancelled.
|
||||||
downloaded = downloaded.toLong(),
|
store.update(shareId, linkId) { stored ->
|
||||||
total = total.toLong(),
|
val current = stored ?: retained
|
||||||
status = if (requestedStatus == RetainedDownload.STATUS_PAUSED ||
|
current.copy(
|
||||||
requestedStatus == RetainedDownload.STATUS_CANCELLED
|
downloaded = downloaded.toLong(),
|
||||||
) requestedStatus else RetainedDownload.STATUS_RUNNING,
|
total = total.toLong(),
|
||||||
)
|
status = when (current.status) {
|
||||||
store.put(retained)
|
RetainedDownload.STATUS_PAUSED,
|
||||||
|
RetainedDownload.STATUS_CANCELLED,
|
||||||
|
-> current.status
|
||||||
|
else -> RetainedDownload.STATUS_RUNNING
|
||||||
|
},
|
||||||
|
).also { retained = it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isCancelled(): Boolean = isStopped
|
override fun isCancelled(): Boolean = isStopped
|
||||||
@@ -62,9 +87,12 @@ class OfflineDownloadWorker(
|
|||||||
},
|
},
|
||||||
onFailure = { failure ->
|
onFailure = { failure ->
|
||||||
if (isStopped) {
|
if (isStopped) {
|
||||||
val current = store.get(shareId, linkId) ?: retained
|
// A pause is also a stop. Under the lock, so the record read
|
||||||
if (current.status != RetainedDownload.STATUS_PAUSED) {
|
// here is the one the write lands on.
|
||||||
store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED))
|
store.update(shareId, linkId) { stored ->
|
||||||
|
val current = stored ?: retained
|
||||||
|
current.takeIf { it.status != RetainedDownload.STATUS_PAUSED }
|
||||||
|
?.copy(status = RetainedDownload.STATUS_CANCELLED)
|
||||||
}
|
}
|
||||||
Result.failure(error("cancelled"))
|
Result.failure(error("cancelled"))
|
||||||
} else if (runAttemptCount < MAX_RETRIES) {
|
} else if (runAttemptCount < MAX_RETRIES) {
|
||||||
@@ -79,11 +107,14 @@ class OfflineDownloadWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
/** Once per run, rather than once per 4 MiB block. */
|
||||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
private fun createNotificationChannel() {
|
||||||
manager.createNotificationChannel(
|
applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle("Making episode available offline")
|
.setContentTitle("Making episode available offline")
|
||||||
@@ -109,6 +140,9 @@ class OfflineDownloadWorker(
|
|||||||
const val KEY_TOTAL = "total"
|
const val KEY_TOTAL = "total"
|
||||||
private const val MAX_RETRIES = 3
|
private const val MAX_RETRIES = 3
|
||||||
private const val CHANNEL_ID = "offline-downloads"
|
private const val CHANNEL_ID = "offline-downloads"
|
||||||
|
|
||||||
|
/** Shortest gap between two identical-looking progress reports. */
|
||||||
|
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.native
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
|
|||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
import uniffi.pstr_android.AndroidSecretStore
|
import uniffi.pstr_android.AndroidSecretStore
|
||||||
|
import uniffi.pstr_android.BridgeException
|
||||||
|
|
||||||
/** Encrypts share fragments and passwords before they reach app preferences. */
|
/**
|
||||||
|
* Encrypts share fragments and passwords before they reach app preferences.
|
||||||
|
*
|
||||||
|
* Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
|
||||||
|
* the FFI have no mapping on the Rust side, and an unmapped callback exception
|
||||||
|
* is a panic on whichever thread was calling — including a tokio worker holding
|
||||||
|
* a download. What the caller does about it is a separate question; being told
|
||||||
|
* at all is this class's part.
|
||||||
|
*/
|
||||||
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
override fun set(key: String, value: String) {
|
override fun set(key: String, value: String) = bridging("store credential") {
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val payload = try {
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
seal(value)
|
||||||
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
} catch (invalidated: KeyPermanentlyInvalidatedException) {
|
||||||
val payload = cipher.iv + encrypted
|
// A lockscreen change or a device restore retires the key. Nothing
|
||||||
|
// it sealed can be read again — and nothing new can be sealed with
|
||||||
|
// it either, so re-entering a link would fail here rather than
|
||||||
|
// restoring access. Everything the old key held is already lost, so
|
||||||
|
// replacing it costs nothing that is not gone.
|
||||||
|
keyStore.deleteEntry(KEY_ALIAS)
|
||||||
|
seal(value)
|
||||||
|
}
|
||||||
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun get(key: String): String? {
|
private fun seal(value: String): ByteArray {
|
||||||
val encoded = preferences.getString(key, null) ?: return null
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||||
|
return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun get(key: String): String? = bridging("read credential") {
|
||||||
|
val encoded = preferences.getString(key, null) ?: return@bridging null
|
||||||
val payload = Base64.decode(encoded, Base64.NO_WRAP)
|
val payload = Base64.decode(encoded, Base64.NO_WRAP)
|
||||||
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
|||||||
encryptionKey(),
|
encryptionKey(),
|
||||||
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
||||||
)
|
)
|
||||||
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(key: String) {
|
override fun delete(key: String) = bridging("delete credential") {
|
||||||
check(preferences.edit().remove(key).commit())
|
check(preferences.edit().remove(key).commit())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun <T> bridging(action: String, block: () -> T): T = try {
|
||||||
|
block()
|
||||||
|
} catch (error: BridgeException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
// The message, never the stack trace: this reaches a snackbar, and the
|
||||||
|
// trace is both unreadable there and a description of app internals.
|
||||||
|
throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
|
||||||
private fun encryptionKey(): SecretKey {
|
private fun encryptionKey(): SecretKey {
|
||||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||||
|
|||||||
@@ -6,20 +6,30 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import uniffi.pstr_android.AndroidEngine
|
import uniffi.pstr_android.AndroidEngine
|
||||||
import uniffi.pstr_android.AndroidPaths
|
import uniffi.pstr_android.AndroidPaths
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
||||||
object NativeRuntime {
|
object NativeRuntime {
|
||||||
private lateinit var appContext: Context
|
private lateinit var appContext: Context
|
||||||
@Volatile private var instance: AndroidEngine? = null
|
@Volatile private var instance: AndroidEngine? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Android's certificate verifier came up.
|
||||||
|
*
|
||||||
|
* False means every HTTPS request will fail validation, which otherwise
|
||||||
|
* surfaces much later as an unexplained certificate error against a share.
|
||||||
|
*/
|
||||||
|
@Volatile var tlsReady: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
initTls(context)
|
tlsReady = initTls(context)
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initializes Android's certificate verifier before Proton networking. */
|
/** Initializes Android's certificate verifier before Proton networking. */
|
||||||
private external fun initTls(context: Context)
|
private external fun initTls(context: Context): Boolean
|
||||||
|
|
||||||
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
||||||
instance ?: synchronized(this@NativeRuntime) {
|
instance ?: synchronized(this@NativeRuntime) {
|
||||||
@@ -27,14 +37,24 @@ object NativeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createEngine(): AndroidEngine {
|
/**
|
||||||
|
* The stored palette, without building the engine.
|
||||||
|
*
|
||||||
|
* Callable from the main thread, and meant to be: it is the only palette
|
||||||
|
* read early enough to decide what the first frame is painted in, and
|
||||||
|
* [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a
|
||||||
|
* Keystore key. This reads one small JSON file and resolves it.
|
||||||
|
*/
|
||||||
|
fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths())
|
||||||
|
|
||||||
|
private fun paths(): AndroidPaths {
|
||||||
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
|
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
|
||||||
val config = File(appContext.noBackupFilesDir, "config")
|
val config = File(appContext.noBackupFilesDir, "config")
|
||||||
val data = File(appContext.filesDir, "data")
|
val data = File(appContext.filesDir, "data")
|
||||||
val cache = File(appContext.cacheDir, "stream")
|
val cache = File(appContext.cacheDir, "stream")
|
||||||
return AndroidEngine(
|
return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
|
||||||
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
|
|
||||||
KeystoreSecretStore(appContext),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createEngine(): AndroidEngine =
|
||||||
|
AndroidEngine(paths(), KeystoreSecretStore(appContext))
|
||||||
}
|
}
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
package io.narl.protonstream.playback
|
|
||||||
|
|
||||||
import android.view.Surface
|
|
||||||
import android.view.SurfaceHolder
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.Pause
|
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
|
||||||
import androidx.compose.material.icons.filled.FullscreenExit
|
|
||||||
import androidx.compose.material.icons.filled.VolumeUp
|
|
||||||
import androidx.compose.material.icons.filled.VolumeOff
|
|
||||||
import androidx.compose.material.icons.filled.Subtitles
|
|
||||||
import androidx.compose.material.icons.filled.Audiotrack
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import uniffi.pstr_android.AndroidEngine
|
|
||||||
import uniffi.pstr_android.AndroidStream
|
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
|
|
||||||
class RustStreamSession(
|
|
||||||
private val episode: EpisodeRecord,
|
|
||||||
) {
|
|
||||||
private var engine: AndroidEngine? = null
|
|
||||||
private var stream: AndroidStream? = null
|
|
||||||
private var handle: ULong? = null
|
|
||||||
private var streamSize: ULong? = null
|
|
||||||
|
|
||||||
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
|
|
||||||
val openedEngine = NativeRuntime.engine()
|
|
||||||
engine = openedEngine
|
|
||||||
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
|
|
||||||
streamSize = it.size()
|
|
||||||
handle = it.nativeHandle()
|
|
||||||
}
|
|
||||||
this@RustStreamSession
|
|
||||||
}
|
|
||||||
|
|
||||||
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
|
|
||||||
|
|
||||||
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
|
|
||||||
|
|
||||||
fun releaseNative(host: LibmpvHost) {
|
|
||||||
handle?.let(host::releaseNativeStream)
|
|
||||||
handle = null
|
|
||||||
streamSize = null
|
|
||||||
stream = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The native player now owns the published token until stop/end/close. */
|
|
||||||
fun transferToPlayer() {
|
|
||||||
handle = null
|
|
||||||
streamSize = null
|
|
||||||
stream = null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun closeEngine() = withContext(Dispatchers.IO) {
|
|
||||||
engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
|
|
||||||
engine = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
|
|
||||||
interface LibmpvHost {
|
|
||||||
fun attachSurface(surface: Surface)
|
|
||||||
fun detachSurface()
|
|
||||||
/** Consumes the opaque handle through pstr_android_stream_{read,size}. */
|
|
||||||
suspend fun play(
|
|
||||||
nativeHandle: ULong,
|
|
||||||
size: ULong,
|
|
||||||
startPosition: Double = 0.0,
|
|
||||||
audioLanguage: String? = null,
|
|
||||||
subtitleLanguage: String? = null,
|
|
||||||
subtitles: Boolean = true,
|
|
||||||
)
|
|
||||||
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
|
||||||
fun releaseNativeStream(nativeHandle: ULong)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class PlaybackStartup(
|
|
||||||
val nativeHandle: ULong,
|
|
||||||
val size: ULong,
|
|
||||||
val startPosition: Double,
|
|
||||||
val audioLanguage: String?,
|
|
||||||
val subtitleLanguage: String?,
|
|
||||||
val subtitles: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LibmpvPlayerSurface(
|
|
||||||
titleKey: String,
|
|
||||||
episode: EpisodeRecord,
|
|
||||||
host: LibmpvHost? = null,
|
|
||||||
isFullscreen: Boolean = false,
|
|
||||||
onToggleFullscreen: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
if (host == null) {
|
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var playbackError by remember(sessionKey(episode)) { mutableStateOf<String?>(null) }
|
|
||||||
val session = remember(episode.shareId, episode.linkId) {
|
|
||||||
RustStreamSession(episode)
|
|
||||||
}
|
|
||||||
LaunchedEffect(session, host) {
|
|
||||||
runCatching {
|
|
||||||
val startup = withContext(Dispatchers.IO) {
|
|
||||||
session.open()
|
|
||||||
val engine = NativeRuntime.engine()
|
|
||||||
val preferences = engine.titleTrackPreferences(titleKey)
|
|
||||||
val watch = engine.watchState(episode.shareId, episode.linkId)
|
|
||||||
PlaybackStartup(
|
|
||||||
nativeHandle = session.nativeHandle(),
|
|
||||||
size = session.size(),
|
|
||||||
startPosition = watch?.positionSecs ?: 0.0,
|
|
||||||
audioLanguage = preferences.audioLanguage,
|
|
||||||
subtitleLanguage = preferences.subtitleLanguage,
|
|
||||||
subtitles = preferences.subtitles,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
host.play(
|
|
||||||
startup.nativeHandle, startup.size, startup.startPosition,
|
|
||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
|
||||||
)
|
|
||||||
session.transferToPlayer()
|
|
||||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
|
||||||
}
|
|
||||||
DisposableEffect(session, host) {
|
|
||||||
onDispose {
|
|
||||||
host.detachSurface()
|
|
||||||
session.releaseNative(host)
|
|
||||||
scope.launch { session.closeEngine() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showControls by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(showControls) {
|
|
||||||
if (showControls) {
|
|
||||||
delay(4000)
|
|
||||||
showControls = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().clickable(
|
|
||||||
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = { showControls = !showControls }
|
|
||||||
)) {
|
|
||||||
AndroidView(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
factory = { context ->
|
|
||||||
SurfaceView(context).apply {
|
|
||||||
holder.addCallback(object : SurfaceHolder.Callback {
|
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = host.attachSurface(holder.surface)
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showControls,
|
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
if (host is NativeMpvHost) PlayerControls(
|
|
||||||
titleKey,
|
|
||||||
episode,
|
|
||||||
host,
|
|
||||||
isFullscreen,
|
|
||||||
onToggleFullscreen
|
|
||||||
)
|
|
||||||
}
|
|
||||||
playbackError?.let { message ->
|
|
||||||
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PlayerControls(
|
|
||||||
titleKey: String,
|
|
||||||
episode: EpisodeRecord,
|
|
||||||
host: NativeMpvHost,
|
|
||||||
isFullscreen: Boolean,
|
|
||||||
onToggleFullscreen: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val state by host.state.collectAsState()
|
|
||||||
val tracks by host.tracks.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var choosing by remember { mutableStateOf<String?>(null) }
|
|
||||||
LaunchedEffect(state.position.roundToInt() / 5) {
|
|
||||||
if (state.duration > 0.0) withContext(Dispatchers.IO) {
|
|
||||||
NativeRuntime.engine().saveWatchState(
|
|
||||||
episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
|
|
||||||
state.ended || state.position >= state.duration * 0.9,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
|
|
||||||
Slider(
|
|
||||||
value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
|
|
||||||
onValueChange = { host.seek(it.toDouble()) },
|
|
||||||
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
|
|
||||||
)
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = { host.setPaused(!state.paused) }) {
|
|
||||||
Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White)
|
|
||||||
}
|
|
||||||
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
IconButton(onClick = { host.setMuted(!state.muted) }) {
|
|
||||||
Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onToggleFullscreen) {
|
|
||||||
Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = { choosing = "audio" }) {
|
|
||||||
Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { choosing = "sub" }) {
|
|
||||||
Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " +
|
|
||||||
"Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}",
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
choosing?.let { type ->
|
|
||||||
TrackChooser(
|
|
||||||
type = type,
|
|
||||||
tracks = tracks.filter { it.type == type },
|
|
||||||
onDismiss = { choosing = null },
|
|
||||||
onSelect = { selected ->
|
|
||||||
host.selectTrack(selected)
|
|
||||||
choosing = null
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
val engine = NativeRuntime.engine()
|
|
||||||
val old = engine.titleTrackPreferences(titleKey)
|
|
||||||
engine.setTitleTrackPreferences(
|
|
||||||
titleKey,
|
|
||||||
if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
|
|
||||||
else old.copy(
|
|
||||||
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
|
|
||||||
subtitles = selected != null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TrackChooser(
|
|
||||||
type: String,
|
|
||||||
tracks: List<MpvTrack>,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onSelect: (MpvTrack?) -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
|
|
||||||
text = {
|
|
||||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
|
||||||
if (type == "sub") {
|
|
||||||
item {
|
|
||||||
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
|
|
||||||
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(tracks, key = MpvTrack::id) { track ->
|
|
||||||
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
|
|
||||||
Text(if (track.selected) "✓ ${track.label}" else track.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTime(seconds: Double): String {
|
|
||||||
val total = seconds.coerceAtLeast(0.0).roundToInt()
|
|
||||||
return "%d:%02d".format(total / 60, total % 60)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.narl.protonstream.playback
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import io.narl.protonstream.ui.ThumbnailSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
import uniffi.pstr_android.ChapterPlan
|
||||||
|
import uniffi.pstr_android.ChapterRecord
|
||||||
|
import uniffi.pstr_android.chapterPlan
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||||
|
* agree on what "finished" means.
|
||||||
|
*
|
||||||
|
* Only [Eof] is an episode watched to the end. Treating the others as one — a
|
||||||
|
* stop, a shutdown, a failed demux — is what marks an episode that never played
|
||||||
|
* as watched and autoplays past it.
|
||||||
|
*/
|
||||||
|
enum class EndReason {
|
||||||
|
None,
|
||||||
|
Eof,
|
||||||
|
Stopped,
|
||||||
|
Quit,
|
||||||
|
Failed,
|
||||||
|
Other,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class MpvPlaybackState(
|
data class MpvPlaybackState(
|
||||||
val position: Double = 0.0,
|
val position: Double = 0.0,
|
||||||
@@ -24,6 +50,53 @@ data class MpvPlaybackState(
|
|||||||
val paused: Boolean = true,
|
val paused: Boolean = true,
|
||||||
val muted: Boolean = false,
|
val muted: Boolean = false,
|
||||||
val ended: Boolean = false,
|
val ended: Boolean = false,
|
||||||
|
/** Meaningful only while [ended]; [EndReason.None] otherwise. */
|
||||||
|
val endReason: EndReason = EndReason.None,
|
||||||
|
/**
|
||||||
|
* Which episode this reading belongs to, or null before the first poll of a
|
||||||
|
* newly loaded file. Everything that *writes* per-episode state — resume
|
||||||
|
* position above all — must check this: one host serves every episode, and
|
||||||
|
* a reading attributed to the wrong one resumes the next episode where the
|
||||||
|
* last one stopped.
|
||||||
|
*/
|
||||||
|
val media: String? = null,
|
||||||
|
/** The picture's display size, or zero until mpv has decoded enough to know. */
|
||||||
|
val videoWidth: Double = 0.0,
|
||||||
|
val videoHeight: Double = 0.0,
|
||||||
|
/**
|
||||||
|
* mpv has run out of buffered data and stopped to refill.
|
||||||
|
*
|
||||||
|
* Not the same as [paused], which is the viewer's doing. A frozen picture
|
||||||
|
* that nothing explains is indistinguishable from a hung app, which is what
|
||||||
|
* a network-backed stream looked like before this existed.
|
||||||
|
*/
|
||||||
|
val buffering: Boolean = false,
|
||||||
|
/** How full the demuxer cache is while [buffering], 0–100. */
|
||||||
|
val cachePercent: Double = 0.0,
|
||||||
|
/** A seek has been issued but playback has not resumed at the new position. */
|
||||||
|
val seeking: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
||||||
|
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is on screen, for the surfaces that are not the player.
|
||||||
|
*
|
||||||
|
* The media session, its notification and Picture-in-Picture all have to name
|
||||||
|
* the episode, and none of them can reach the composition that knows it. The
|
||||||
|
* player publishes this once per episode and they read it.
|
||||||
|
*/
|
||||||
|
data class NowPlaying(
|
||||||
|
val media: String,
|
||||||
|
val title: String,
|
||||||
|
val show: String?,
|
||||||
|
val detail: String?,
|
||||||
|
val artworkUrl: String?,
|
||||||
|
/** The file to pull a Proton thumbnail from when the provider has no poster. */
|
||||||
|
val artworkFile: ThumbnailSource?,
|
||||||
|
val hasPrevious: Boolean,
|
||||||
|
val hasNext: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MpvTrack(
|
data class MpvTrack(
|
||||||
@@ -38,21 +111,61 @@ data class MpvTrack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
||||||
class NativeMpvHost : LibmpvHost, AutoCloseable {
|
class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
|
||||||
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
|
|
||||||
private val lifecycle = ReentrantLock()
|
private val lifecycle = ReentrantLock()
|
||||||
@Volatile private var closed = false
|
@Volatile private var closed = false
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
||||||
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
|
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
|
||||||
|
private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
|
||||||
|
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||||
|
private val mutableAdvancing = MutableStateFlow(false)
|
||||||
|
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||||
private var poller: Job? = null
|
private var poller: Job? = null
|
||||||
private var currentStreamHandle: Long? = null
|
/** The episode key and load generation the native player currently holds. */
|
||||||
|
@Volatile private var currentMedia: String? = null
|
||||||
|
@Volatile private var currentGeneration: Double = -1.0
|
||||||
|
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||||
var onPlaybackStarted: (() -> Unit)? = null
|
var onPlaybackStarted: (() -> Unit)? = null
|
||||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||||
var onExplicitStop: (() -> Unit)? = null
|
var onExplicitStop: (() -> Unit)? = null
|
||||||
|
/**
|
||||||
|
* Where a transport outside the player sends previous/next.
|
||||||
|
*
|
||||||
|
* The player owns which episode is open, so the notification, the lock
|
||||||
|
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
|
||||||
|
* run on the main thread, which is where the player's state lives.
|
||||||
|
*/
|
||||||
|
var onSkipPrevious: (() -> Unit)? = null
|
||||||
|
var onSkipNext: (() -> Unit)? = null
|
||||||
|
|
||||||
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
||||||
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
|
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
|
||||||
|
/** The open file's chapters, already resolved into openings and endings. */
|
||||||
|
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
|
||||||
|
/** The episode the player has open, for every surface that has to name it. */
|
||||||
|
val nowPlaying: StateFlow<NowPlaying?> = mutableNowPlaying.asStateFlow()
|
||||||
|
/**
|
||||||
|
* True while the player is between episodes.
|
||||||
|
*
|
||||||
|
* The end of one episode and the start of the next are separated by a
|
||||||
|
* network round trip — opening the next revision's stream. The foreground
|
||||||
|
* service must not retire in that gap: with no foreground state left, the
|
||||||
|
* `startForeground` on the other side is a background start, which API 31+
|
||||||
|
* refuses outright.
|
||||||
|
*/
|
||||||
|
val advancing: StateFlow<Boolean> = mutableAdvancing.asStateFlow()
|
||||||
|
/**
|
||||||
|
* What mpv last complained about, until the viewer dismisses it.
|
||||||
|
*
|
||||||
|
* Errors mpv reports are about the *file* — an unreadable header, a codec it
|
||||||
|
* has no decoder for — and reach nothing else: the play call has already
|
||||||
|
* returned successfully by the time they happen.
|
||||||
|
*/
|
||||||
|
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||||
|
|
||||||
|
/** Dismiss the current [problem]. */
|
||||||
|
fun clearProblem() { mutableProblem.value = null }
|
||||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||||
|
|
||||||
override fun attachSurface(surface: Surface) {
|
override fun attachSurface(surface: Surface) {
|
||||||
@@ -64,6 +177,7 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun play(
|
override suspend fun play(
|
||||||
|
media: String,
|
||||||
nativeHandle: ULong,
|
nativeHandle: ULong,
|
||||||
size: ULong,
|
size: ULong,
|
||||||
startPosition: Double,
|
startPosition: Double,
|
||||||
@@ -72,69 +186,172 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
) {
|
) {
|
||||||
check(size > 0uL) { "Cannot play an empty stream" }
|
check(size > 0uL) { "Cannot play an empty stream" }
|
||||||
withContext(Dispatchers.IO) {
|
try {
|
||||||
val loaded = withOpenHandle {
|
withContext(Dispatchers.IO) {
|
||||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
val loaded = withOpenHandle {
|
||||||
if (loaded) {
|
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
||||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
if (loaded) {
|
||||||
currentStreamHandle = nativeHandle.toLong()
|
// The outgoing stream is released by mpv closing it,
|
||||||
|
// not from here: `loadfile` is asynchronous and its
|
||||||
|
// demuxer may still be reading. `stream_close` in
|
||||||
|
// `pstr_mpv.cpp` owns that release.
|
||||||
|
//
|
||||||
|
// The outgoing file's readings describe an episode
|
||||||
|
// that is no longer open. Retire them here as well
|
||||||
|
// as natively, so nothing observes them even once.
|
||||||
|
currentMedia = media
|
||||||
|
currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
|
||||||
|
mutableState.value = MpvPlaybackState(
|
||||||
|
position = startPosition,
|
||||||
|
volume = mutableState.value.volume,
|
||||||
|
muted = mutableState.value.muted,
|
||||||
|
media = media,
|
||||||
|
)
|
||||||
|
mutableProblem.value = null
|
||||||
|
mutableTracks.value = emptyList()
|
||||||
|
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||||
|
plannedFor = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} ?: false
|
||||||
|
check(loaded) {
|
||||||
|
"libmpv rejected the stream or no video surface was available"
|
||||||
}
|
}
|
||||||
} ?: false
|
|
||||||
check(loaded) {
|
|
||||||
"libmpv rejected the stream or no video surface was available"
|
|
||||||
}
|
}
|
||||||
}
|
withOpenHandle {
|
||||||
withOpenHandle {
|
onPlaybackStarted?.invoke()
|
||||||
onPlaybackStarted?.invoke()
|
startPolling()
|
||||||
startPolling()
|
}
|
||||||
|
} finally {
|
||||||
|
// Whether the next episode arrived or failed to, the gap the
|
||||||
|
// foreground service was holding open is over.
|
||||||
|
mutableAdvancing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
|
/**
|
||||||
|
* Declare that the player is walking to another episode.
|
||||||
|
*
|
||||||
|
* Called before the stream for the next one is opened, and cleared when
|
||||||
|
* [play] returns either way — the window this covers is the one in which
|
||||||
|
* nothing is loaded and the service would otherwise conclude playback is
|
||||||
|
* finished.
|
||||||
|
*/
|
||||||
|
fun beginTransition() { mutableAdvancing.value = true }
|
||||||
|
|
||||||
|
/** Names the open episode for the media session, its notification and PiP. */
|
||||||
|
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||||
|
|
||||||
|
fun setPaused(paused: Boolean) {
|
||||||
|
// The native side writes the flag through, so every transport can flip
|
||||||
|
// now instead of on the next poll a quarter second from here.
|
||||||
|
val next = withOpenHandle {
|
||||||
|
nativePause(it, paused)
|
||||||
|
mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
|
||||||
|
} ?: return
|
||||||
|
onStateChanged?.invoke(next)
|
||||||
|
}
|
||||||
|
|
||||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||||
|
|
||||||
|
/** Playback rate, 1.0 being the file's own. Clamped native-side. */
|
||||||
|
fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
|
||||||
fun selectTrack(track: MpvTrack?) {
|
fun selectTrack(track: MpvTrack?) {
|
||||||
val audio = track?.type == "audio"
|
val audio = track?.type == "audio"
|
||||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explicit user stop: stop media, release its stream, and retire the foreground service. */
|
/**
|
||||||
|
* Explicit user stop: stop media and retire the foreground service.
|
||||||
|
*
|
||||||
|
* The stream behind the open file is released by mpv closing it, which
|
||||||
|
* `stop` causes; releasing it from here would race that close.
|
||||||
|
*/
|
||||||
fun stop() {
|
fun stop() {
|
||||||
val callback = withOpenHandle {
|
val callback = withOpenHandle {
|
||||||
nativeStop(it)
|
nativeStop(it)
|
||||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
// Nothing loaded means nothing to attribute a reading to: a save
|
||||||
currentStreamHandle = null
|
// issued after this must not land on the episode just stopped.
|
||||||
|
currentMedia = null
|
||||||
|
mutableAdvancing.value = false
|
||||||
|
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||||
|
mutableNowPlaying.value = null
|
||||||
onExplicitStop
|
onExplicitStop
|
||||||
}
|
}
|
||||||
callback?.invoke()
|
callback?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun releaseEndedStream() {
|
|
||||||
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||||
|
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
if (poller?.isActive == true) return
|
if (poller?.isActive == true) return
|
||||||
poller = scope.launch {
|
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||||
|
// are synchronous `mpv_get_property` calls — five per track for the
|
||||||
|
// latter — that contend on the core lock the demuxer holds during a
|
||||||
|
// fetch. A poll that blocks there blocks the UI thread with it.
|
||||||
|
poller = scope.launch(Dispatchers.Default) {
|
||||||
var tick = 0
|
var tick = 0
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val values = withOpenHandle(::nativeState) ?: break
|
val values = withOpenHandle(::nativeState) ?: break
|
||||||
if (values.size >= 6) {
|
if (values.size >= FIELDS) {
|
||||||
val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0)
|
val next = MpvPlaybackState(
|
||||||
|
values[0], values[1], values[2],
|
||||||
|
values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
|
||||||
|
// Only a reading of the file this host was last asked
|
||||||
|
// to load carries its episode's name.
|
||||||
|
media = currentMedia.takeIf { values.generation() == currentGeneration },
|
||||||
|
videoWidth = values[WIDTH],
|
||||||
|
videoHeight = values[HEIGHT],
|
||||||
|
endReason = EndReason.of(values[END_REASON]),
|
||||||
|
buffering = values[BUFFERING] != 0.0,
|
||||||
|
cachePercent = values[CACHE_PERCENT],
|
||||||
|
seeking = values[SEEKING] != 0.0,
|
||||||
|
)
|
||||||
mutableState.value = next
|
mutableState.value = next
|
||||||
onStateChanged?.invoke(next)
|
// The listeners drive the media session and the player UI,
|
||||||
|
// both of which are the main thread's.
|
||||||
|
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||||
|
refreshChapters(next)
|
||||||
}
|
}
|
||||||
|
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||||
if (tick++ % 4 == 0) refreshTracks()
|
if (tick++ % 4 == 0) refreshTracks()
|
||||||
delay(250)
|
delay(250)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the chapters when there is a reason to: a different file, or a
|
||||||
|
* duration that has only just arrived. Both change the answer — the length
|
||||||
|
* of a chapter is what settles whether `Intro` is an opening or ten minutes
|
||||||
|
* of story — and neither changes again after that, so this settles quickly
|
||||||
|
* and then costs one comparison per poll.
|
||||||
|
*/
|
||||||
|
private fun refreshChapters(state: MpvPlaybackState) {
|
||||||
|
val generation = currentGeneration
|
||||||
|
val wanted = state.duration to generation.toRawBits()
|
||||||
|
if (plannedFor == wanted) return
|
||||||
|
runCatching {
|
||||||
|
val encoded = withOpenHandle(::nativeChapters) ?: return
|
||||||
|
val values = JSONArray(encoded)
|
||||||
|
List(values.length()) { index ->
|
||||||
|
values.getJSONObject(index).run {
|
||||||
|
ChapterRecord(
|
||||||
|
index = getLong("index"),
|
||||||
|
title = optString("title").takeIf(String::isNotBlank),
|
||||||
|
start = getDouble("start"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess { chapters ->
|
||||||
|
plannedFor = wanted
|
||||||
|
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshTracks() {
|
private fun refreshTracks() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||||
@@ -149,16 +366,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
lifecycle.withLock {
|
val destroy = lifecycle.withLock {
|
||||||
if (closed) return@withLock
|
if (closed) return@withLock false
|
||||||
closed = true
|
closed = true
|
||||||
onPlaybackStarted = null
|
onPlaybackStarted = null
|
||||||
onStateChanged = null
|
onStateChanged = null
|
||||||
onExplicitStop = null
|
onExplicitStop = null
|
||||||
nativeDestroy(nativeHandle)
|
onSkipPrevious = null
|
||||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
onSkipNext = null
|
||||||
currentStreamHandle = null
|
mutableNowPlaying.value = null
|
||||||
|
mutableAdvancing.value = false
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
if (!destroy) return
|
||||||
|
// `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
|
||||||
|
// parked in a block fetch over a degraded connection. This is called
|
||||||
|
// from Service.onDestroy — on the main thread — so waiting for it there
|
||||||
|
// is an ANR. `closed` is already set, so nothing new enters the handle;
|
||||||
|
// the lock is only to let a call that is already inside finish.
|
||||||
|
//
|
||||||
|
// Terminating the core closes the open file, and closing it releases the
|
||||||
|
// stream — after mpv's last read rather than during it.
|
||||||
|
Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serializes destruction against every JNI use of the raw Player pointer. */
|
/** Serializes destruction against every JNI use of the raw Player pointer. */
|
||||||
@@ -166,7 +395,6 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
if (closed) null else block(nativeHandle)
|
if (closed) null else block(nativeHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
private external fun nativeCreate(): Long
|
|
||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||||
private external fun nativeDetachSurface(handle: Long)
|
private external fun nativeDetachSurface(handle: Long)
|
||||||
@@ -174,17 +402,47 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
private external fun nativePause(handle: Long, paused: Boolean)
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
private external fun nativeSeek(handle: Long, position: Double)
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
private external fun nativeVolume(handle: Long, volume: Double)
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
|
private external fun nativeSpeed(handle: Long, speed: Double)
|
||||||
private external fun nativeMute(handle: Long, muted: Boolean)
|
private external fun nativeMute(handle: Long, muted: Boolean)
|
||||||
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
||||||
private external fun nativeStop(handle: Long)
|
private external fun nativeStop(handle: Long)
|
||||||
private external fun nativeState(handle: Long): DoubleArray
|
private external fun nativeState(handle: Long): DoubleArray
|
||||||
private external fun nativeTracks(handle: Long): String
|
private external fun nativeTakeError(handle: Long): String?
|
||||||
|
// Nullable: `NewStringUTF` returns null when the string cannot be built,
|
||||||
|
// and a non-null declaration turns that into an NPE that `runCatching`
|
||||||
|
// swallows into a silently empty track list.
|
||||||
|
private external fun nativeTracks(handle: Long): String?
|
||||||
|
private external fun nativeChapters(handle: Long): String?
|
||||||
private external fun pstrAndroidStreamRelease(handle: Long)
|
private external fun pstrAndroidStreamRelease(handle: Long)
|
||||||
|
|
||||||
|
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||||
|
private const val FIELDS = 13
|
||||||
|
private const val GENERATION = 6
|
||||||
|
private const val WIDTH = 7
|
||||||
|
private const val HEIGHT = 8
|
||||||
|
private const val END_REASON = 9
|
||||||
|
private const val BUFFERING = 10
|
||||||
|
private const val CACHE_PERCENT = 11
|
||||||
|
private const val SEEKING = 12
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
System.loadLibrary("pstr_mpv")
|
System.loadLibrary("pstr_mpv")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host over a live libmpv core, or null if libmpv would not start.
|
||||||
|
*
|
||||||
|
* Null rather than an exception: the service that owns this is created
|
||||||
|
* by the system, so a throw out of the constructor takes the process
|
||||||
|
* with it — and the app has a graceful "this build does not include
|
||||||
|
* libmpv" screen that a crash makes unreachable.
|
||||||
|
*/
|
||||||
|
fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
|
||||||
|
|
||||||
|
@JvmStatic private external fun nativeCreate(): Long
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,12 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
|
|||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import io.narl.protonstream.MainActivity
|
import io.narl.protonstream.MainActivity
|
||||||
|
import io.narl.protonstream.R
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.ui.loadArtwork
|
||||||
|
import io.narl.protonstream.ui.loadThumbnail
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||||
class PlaybackService : Service() {
|
class PlaybackService : Service() {
|
||||||
inner class PlaybackBinder : Binder() {
|
inner class PlaybackBinder : Binder() {
|
||||||
val host: NativeMpvHost get() = this@PlaybackService.host
|
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var host: NativeMpvHost
|
/**
|
||||||
|
* The libmpv core, or null when this build could not start one.
|
||||||
|
*
|
||||||
|
* Nullable rather than a `lateinit` that throws: the system creates this
|
||||||
|
* service, so a failure in its constructor takes the process down and makes
|
||||||
|
* the app's own "playback is unavailable" screen unreachable.
|
||||||
|
*/
|
||||||
|
private var host: NativeMpvHost? = null
|
||||||
private lateinit var mediaSession: MediaSession
|
private lateinit var mediaSession: MediaSession
|
||||||
private lateinit var audioManager: AudioManager
|
private lateinit var audioManager: AudioManager
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private var audioFocus: AudioFocusRequest? = null
|
private var audioFocus: AudioFocusRequest? = null
|
||||||
private var notifiedPaused: Boolean? = null
|
/** Whether the pause on screen is this service's doing, not the viewer's. */
|
||||||
|
private var pausedForFocus = false
|
||||||
|
/** The volume before ducking, so the restore does not read a ducked one. */
|
||||||
|
private var beforeDucking: Double? = null
|
||||||
|
/**
|
||||||
|
* Pulling the headphone jack must not continue on the speaker.
|
||||||
|
*
|
||||||
|
* Android broadcasts this and expects every media app to handle it; the
|
||||||
|
* alternative is an episode playing out loud in a quiet room.
|
||||||
|
*/
|
||||||
|
private val becomingNoisy = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
private var foregroundPlayback = false
|
private var foregroundPlayback = false
|
||||||
private var sessionPlayback = false
|
private var sessionPlayback = false
|
||||||
|
/**
|
||||||
|
* What the posted notification already says.
|
||||||
|
*
|
||||||
|
* The state poll runs four times a second and almost none of those readings
|
||||||
|
* change anything the shade shows. Re-posting on every one of them animates
|
||||||
|
* the media control and costs a binder round trip per tick, so the
|
||||||
|
* notification is rebuilt only when its own inputs move.
|
||||||
|
*/
|
||||||
|
private var notified: NotificationShape? = null
|
||||||
|
/** The pending teardown, held so an episode transition can call it off. */
|
||||||
|
private var retire: Job? = null
|
||||||
|
/** The episode's artwork, and the source key it was fetched for. */
|
||||||
|
private var artwork: Pair<String, Bitmap>? = null
|
||||||
private val binder = PlaybackBinder()
|
private val binder = PlaybackBinder()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createChannel()
|
createChannel()
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
becomingNoisy,
|
||||||
|
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
audioManager = getSystemService(AudioManager::class.java)
|
audioManager = getSystemService(AudioManager::class.java)
|
||||||
host = NativeMpvHost().apply {
|
host = NativeMpvHost.createOrNull()?.apply {
|
||||||
onPlaybackStarted = {
|
onPlaybackStarted = {
|
||||||
requestAudioFocus()
|
// A refusal is rare — a call in progress — but playing over it
|
||||||
|
// is worse than not playing, and the viewer can simply press
|
||||||
|
// play again once the call ends.
|
||||||
|
if (!requestAudioFocus()) setPaused(true)
|
||||||
sessionPlayback = true
|
sessionPlayback = true
|
||||||
if (SettingsStore(this@PlaybackService).backgroundAudio) {
|
cancelRetire()
|
||||||
|
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||||
// Convert the bound service into a started foreground
|
// Convert the bound service into a started foreground
|
||||||
// service only when the viewer opted to keep audio alive.
|
// service only when the viewer opted to keep audio alive.
|
||||||
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
// Already-foreground is the ordinary case across an episode
|
||||||
startForeground(NOTIFICATION_ID, notification(false))
|
// transition, and re-entering costs a notification rebuild.
|
||||||
foregroundPlayback = true
|
foregroundPlayback = runCatching {
|
||||||
|
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||||
|
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
|
||||||
|
}.isSuccess
|
||||||
}
|
}
|
||||||
mediaSession.isActive = true
|
mediaSession.isActive = true
|
||||||
}
|
}
|
||||||
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
|
|||||||
onExplicitStop = { retirePlayback() }
|
onExplicitStop = { retirePlayback() }
|
||||||
}
|
}
|
||||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||||
|
setSessionActivity(openActivity())
|
||||||
setCallback(object : MediaSession.Callback() {
|
setCallback(object : MediaSession.Callback() {
|
||||||
override fun onPlay() = host.setPaused(false)
|
override fun onPlay() { host?.setPaused(false) }
|
||||||
override fun onPause() = host.setPaused(true)
|
override fun onPause() { host?.setPaused(true) }
|
||||||
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0)
|
override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
|
||||||
override fun onStop() = host.stop()
|
override fun onStop() { host?.stop() }
|
||||||
|
override fun onSkipToNext() { host?.onSkipNext?.invoke() }
|
||||||
|
override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Artwork arrives after the episode does, and the shape of the transport
|
||||||
|
// changes with the playlist position, so the notification follows the
|
||||||
|
// published episode rather than being built once when playback starts.
|
||||||
|
val core = host ?: return
|
||||||
|
scope.launch {
|
||||||
|
core.nowPlaying.collect { playing ->
|
||||||
|
// Keyed by whichever source the episode has: with metadata off
|
||||||
|
// there is no provider poster, and Proton's own thumbnail is
|
||||||
|
// the only thing the shade can show.
|
||||||
|
artwork = playing?.let { episode ->
|
||||||
|
val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
|
||||||
|
artwork?.takeIf { it.first == key }
|
||||||
|
?: (
|
||||||
|
loadArtwork(this@PlaybackService, episode.artworkUrl)
|
||||||
|
?: loadThumbnail(episode.artworkFile)
|
||||||
|
)?.let { key to it }
|
||||||
|
}
|
||||||
|
refreshSession(core.state.value, playing)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder = binder
|
override fun onBind(intent: Intent?): IBinder = binder
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_PLAY -> host.setPaused(false)
|
ACTION_PLAY -> host?.setPaused(false)
|
||||||
ACTION_PAUSE -> host.setPaused(true)
|
ACTION_PAUSE -> host?.setPaused(true)
|
||||||
ACTION_STOP -> host.stop()
|
ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
|
||||||
|
ACTION_NEXT -> host?.onSkipNext?.invoke()
|
||||||
|
ACTION_STOP -> host?.stop()
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
runCatching { unregisterReceiver(becomingNoisy) }
|
||||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
host.close()
|
host?.close()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
/**
|
||||||
|
* Take audio focus, and honour what the system says about it afterwards.
|
||||||
|
*
|
||||||
|
* The four cases are genuinely different and collapsing them is what made
|
||||||
|
* this feel broken: a phone call is transient and playback should come back
|
||||||
|
* by itself; a navigation prompt wants the volume down, not a pause; another
|
||||||
|
* media app taking over for good means giving the focus back rather than
|
||||||
|
* holding it; and a refusal means not playing at all.
|
||||||
|
*/
|
||||||
|
private fun requestAudioFocus(): Boolean {
|
||||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||||
)
|
)
|
||||||
|
.setWillPauseWhenDucked(false)
|
||||||
.setOnAudioFocusChangeListener { focus ->
|
.setOnAudioFocusChangeListener { focus ->
|
||||||
when (focus) {
|
when (focus) {
|
||||||
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
AudioManager.AUDIOFOCUS_GAIN -> Unit
|
pausedForFocus = false
|
||||||
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
|
audioFocus = null
|
||||||
|
host?.setPaused(true)
|
||||||
|
}
|
||||||
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||||
|
// Only resume what this paused: a viewer who paused
|
||||||
|
// before the phone rang expects it still paused after.
|
||||||
|
pausedForFocus = host?.state?.value?.paused == false
|
||||||
|
host?.setPaused(true)
|
||||||
|
}
|
||||||
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||||
|
host?.let { core ->
|
||||||
|
val current = core.state.value.volume
|
||||||
|
beforeDucking = beforeDucking ?: current
|
||||||
|
core.setVolume(current * DUCK_FACTOR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||||
|
beforeDucking?.let { host?.setVolume(it) }
|
||||||
|
beforeDucking = null
|
||||||
|
if (pausedForFocus) {
|
||||||
|
pausedForFocus = false
|
||||||
|
host?.setPaused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
audioFocus = request
|
audioFocus = request
|
||||||
audioManager.requestAudioFocus(request)
|
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSession(state: MpvPlaybackState) {
|
private fun updateSession(state: MpvPlaybackState) {
|
||||||
|
refreshSession(state, host?.nowPlaying?.value)
|
||||||
|
if (state.ended) scheduleRetire() else cancelRetire()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire the session once playback is really over.
|
||||||
|
*
|
||||||
|
* "This file ended" is not "playback is over": autoplay walks to the next
|
||||||
|
* episode, and opening its stream is a network round trip. Retiring inside
|
||||||
|
* that gap leaves the app with no foreground state at all, so the
|
||||||
|
* `startForeground` on the far side is a background start — which API 31+
|
||||||
|
* refuses with `ForegroundServiceStartNotAllowedException`, and this app's
|
||||||
|
* minSdk is 31.
|
||||||
|
*/
|
||||||
|
private fun scheduleRetire() {
|
||||||
|
if (retire?.isActive == true) return
|
||||||
|
retire = scope.launch {
|
||||||
|
// The end of file reaches the player and this service from the same
|
||||||
|
// poll, and the player's advance is a recomposition behind. Give it
|
||||||
|
// that long to declare a transition before concluding there is none.
|
||||||
|
delay(TRANSITION_SETTLE_MS)
|
||||||
|
val host = this@PlaybackService.host ?: return@launch
|
||||||
|
withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
|
||||||
|
if (host.state.value.ended) retirePlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetire() {
|
||||||
|
retire?.cancel()
|
||||||
|
retire = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
|
||||||
val status = when {
|
val status = when {
|
||||||
state.ended -> PlaybackState.STATE_STOPPED
|
state.ended -> PlaybackState.STATE_STOPPED
|
||||||
state.paused -> PlaybackState.STATE_PAUSED
|
state.paused -> PlaybackState.STATE_PAUSED
|
||||||
else -> PlaybackState.STATE_PLAYING
|
else -> PlaybackState.STATE_PLAYING
|
||||||
}
|
}
|
||||||
|
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
|
||||||
|
PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
|
||||||
|
if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||||
|
if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||||
mediaSession.setPlaybackState(
|
mediaSession.setPlaybackState(
|
||||||
PlaybackState.Builder()
|
PlaybackState.Builder()
|
||||||
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
|
.setActions(actions)
|
||||||
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
mediaSession.setMetadata(
|
mediaSession.setMetadata(metadata(state, playing))
|
||||||
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(),
|
val shape = NotificationShape(
|
||||||
|
paused = state.paused,
|
||||||
|
media = playing?.media,
|
||||||
|
title = playing?.title,
|
||||||
|
subtitle = playing?.show,
|
||||||
|
hasPrevious = playing?.hasPrevious == true,
|
||||||
|
hasNext = playing?.hasNext == true,
|
||||||
|
hasArtwork = artwork != null,
|
||||||
)
|
)
|
||||||
if (state.ended && foregroundPlayback) {
|
if (foregroundPlayback && notified != shape) {
|
||||||
host.releaseEndedStream()
|
notified = shape
|
||||||
retirePlayback()
|
getSystemService(NotificationManager::class.java)
|
||||||
return
|
.notify(NOTIFICATION_ID, notification(state, playing))
|
||||||
}
|
|
||||||
if (foregroundPlayback && notifiedPaused != state.paused) {
|
|
||||||
notifiedPaused = state.paused
|
|
||||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the shade, the lock screen and Android Auto read.
|
||||||
|
*
|
||||||
|
* The episode is the title and the show is the artist, which is the mapping
|
||||||
|
* every media surface already lays out well: one prominent line, one
|
||||||
|
* secondary, and the poster behind both.
|
||||||
|
*/
|
||||||
|
private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
|
||||||
|
.apply {
|
||||||
|
playing?.show?.let {
|
||||||
|
putString(MediaMetadata.METADATA_KEY_ARTIST, it)
|
||||||
|
putString(MediaMetadata.METADATA_KEY_ALBUM, it)
|
||||||
|
putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
|
||||||
|
}
|
||||||
|
playing?.detail?.let {
|
||||||
|
putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
|
||||||
|
}
|
||||||
|
artwork?.second?.let {
|
||||||
|
putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
|
||||||
|
putBitmap(MediaMetadata.METADATA_KEY_ART, it)
|
||||||
|
putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun retirePlayback() {
|
private fun retirePlayback() {
|
||||||
|
cancelRetire()
|
||||||
if (!sessionPlayback && !foregroundPlayback) return
|
if (!sessionPlayback && !foregroundPlayback) return
|
||||||
sessionPlayback = false
|
sessionPlayback = false
|
||||||
notifiedPaused = null
|
notified = null
|
||||||
|
artwork = null
|
||||||
mediaSession.isActive = false
|
mediaSession.isActive = false
|
||||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
audioFocus = null
|
audioFocus = null
|
||||||
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notification(paused: Boolean): Notification {
|
private fun openActivity(): PendingIntent = PendingIntent.getActivity(
|
||||||
val open = PendingIntent.getActivity(
|
this,
|
||||||
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
0,
|
||||||
)
|
Intent(this, MainActivity::class.java),
|
||||||
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
val toggle = PendingIntent.getService(
|
)
|
||||||
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
)
|
private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
|
||||||
val stop = PendingIntent.getService(
|
this,
|
||||||
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
requestCode,
|
||||||
)
|
Intent(this, PlaybackService::class.java).setAction(action),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
|
||||||
|
Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
|
||||||
|
|
||||||
|
private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
|
||||||
|
val paused = state.paused
|
||||||
|
val actions = buildList {
|
||||||
|
if (playing?.hasPrevious == true) {
|
||||||
|
add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
|
||||||
|
else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
|
||||||
|
)
|
||||||
|
if (playing?.hasNext == true) {
|
||||||
|
add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
|
||||||
|
}
|
||||||
|
add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
|
||||||
|
}
|
||||||
|
val style = Notification.MediaStyle()
|
||||||
|
.setMediaSession(mediaSession.sessionToken)
|
||||||
|
// The compact view holds three: the transport as it exists for this
|
||||||
|
// episode, so a first episode does not show a dead Previous.
|
||||||
|
.setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
|
||||||
return Notification.Builder(this, CHANNEL_ID)
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
.setContentTitle("proton-stream")
|
.setContentTitle(playing?.title ?: "proton-stream")
|
||||||
.setContentText(if (paused) "Playback paused" else "Playing")
|
.setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
|
||||||
.setContentIntent(open)
|
.apply {
|
||||||
|
playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
|
||||||
|
artwork?.second?.let { setLargeIcon(it) }
|
||||||
|
}
|
||||||
|
.setColor(ACCENT)
|
||||||
|
.setColorized(true)
|
||||||
|
.setContentIntent(openActivity())
|
||||||
|
.setDeleteIntent(command(2, ACTION_STOP))
|
||||||
|
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||||
.setOngoing(!paused)
|
.setOngoing(!paused)
|
||||||
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build())
|
.apply { actions.forEach { addAction(it) } }
|
||||||
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
|
.setStyle(style)
|
||||||
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createChannel() {
|
private fun createChannel() {
|
||||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
|
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = "Transport controls for the episode being played."
|
||||||
|
setShowBadge(false)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The inputs the posted notification is built from, and nothing else. */
|
||||||
|
private data class NotificationShape(
|
||||||
|
val paused: Boolean,
|
||||||
|
val media: String?,
|
||||||
|
val title: String?,
|
||||||
|
val subtitle: String?,
|
||||||
|
val hasPrevious: Boolean,
|
||||||
|
val hasNext: Boolean,
|
||||||
|
val hasArtwork: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CHANNEL_ID = "playback"
|
private const val CHANNEL_ID = "playback"
|
||||||
private const val NOTIFICATION_ID = 47
|
private const val NOTIFICATION_ID = 47
|
||||||
|
/** How long a player gets to declare that it is changing episodes. */
|
||||||
|
private const val TRANSITION_SETTLE_MS = 1_000L
|
||||||
|
/** And how long that transition may then take before it is abandoned. */
|
||||||
|
private const val TRANSITION_GRACE_MS = 30_000L
|
||||||
|
/** How far the volume drops for a navigation prompt or a notification. */
|
||||||
|
private const val DUCK_FACTOR = 0.3
|
||||||
|
|
||||||
|
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||||
|
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||||
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
|
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
|
||||||
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
||||||
|
private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
|
||||||
|
private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
|
||||||
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,984 @@
|
|||||||
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Toc
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
|
import androidx.compose.material.icons.filled.Audiotrack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Speed
|
||||||
|
import androidx.compose.material.icons.filled.Forward30
|
||||||
|
import androidx.compose.material.icons.filled.Fullscreen
|
||||||
|
import androidx.compose.material.icons.filled.FullscreenExit
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Replay10
|
||||||
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
|
import androidx.compose.material.icons.filled.SkipPrevious
|
||||||
|
import androidx.compose.material.icons.filled.Subtitles
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentTrack
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import io.narl.protonstream.ui.thumbnailSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.ChapterEntry
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TrackPreferencesRecord
|
||||||
|
import uniffi.pstr_android.skipOffer
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** How long the controls stay up after a tap. */
|
||||||
|
private const val CONTROLS_SECONDS = 4L
|
||||||
|
|
||||||
|
/** How long the "up next" card counts down before it loads the next episode. */
|
||||||
|
private const val UP_NEXT_SECONDS = 10
|
||||||
|
|
||||||
|
/** Past this much of an episode it counts as watched. */
|
||||||
|
private const val WATCHED_FRACTION = 0.9
|
||||||
|
|
||||||
|
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||||
|
private const val MAX_VOLUME = 100f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrowest layout the in-app volume slider is worth the room it costs.
|
||||||
|
*
|
||||||
|
* Below it — a phone held upright — the transport row is already five controls
|
||||||
|
* wide, and the device's own volume keys are the better answer.
|
||||||
|
*/
|
||||||
|
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player, as its own screen on the activity's own window.
|
||||||
|
*
|
||||||
|
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
|
||||||
|
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
|
||||||
|
* activity window is what makes "fullscreen" mean the whole panel, notch
|
||||||
|
* included.
|
||||||
|
*
|
||||||
|
* [episodes] is the title's episodes in display order and [index] the one that is
|
||||||
|
* open, which is what previous/next and autoplay walk. The index is hoisted so
|
||||||
|
* that it survives process recreation with the rest of the navigation state —
|
||||||
|
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
|
||||||
|
*
|
||||||
|
* In [inPictureInPicture] the window is a thumbnail with the system's own
|
||||||
|
* controls over it, so this screen draws the picture and nothing else.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PlayerScreen(
|
||||||
|
title: TitleRecord,
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
index: Int,
|
||||||
|
host: LibmpvHost?,
|
||||||
|
inPictureInPicture: Boolean,
|
||||||
|
minimized: Boolean,
|
||||||
|
onIndexChange: (Int) -> Unit,
|
||||||
|
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
||||||
|
onMinimize: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as? Activity
|
||||||
|
val titleKey = title.key
|
||||||
|
val episode = episodes.getOrNull(index)
|
||||||
|
|
||||||
|
// Minimised, this screen keeps the stream and the effects that own it, and
|
||||||
|
// draws nothing: no surface — mpv plays on without one, exactly as it does
|
||||||
|
// while the app is in the background — no immersive window, and no back
|
||||||
|
// handler, because back belongs to the page the viewer went to.
|
||||||
|
if (!minimized) {
|
||||||
|
ImmersiveWindow(activity)
|
||||||
|
// Back leaves the video rather than ending it. Stopping is the mini
|
||||||
|
// transport's ✕, which is the one place it is unambiguous.
|
||||||
|
BackHandler(onBack = onMinimize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The transport reads state only this host publishes, so the interface
|
||||||
|
// alone is not enough to draw a player.
|
||||||
|
val nativeHost = host as? NativeMpvHost
|
||||||
|
if (nativeHost == null || episode == null) {
|
||||||
|
if (minimized) return
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
if (nativeHost == null) "Playback is unavailable: this build does not include libmpv."
|
||||||
|
else "This title has no episode to play.",
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val key = mediaKey(episode)
|
||||||
|
val session = remember(key) { RustStreamSession(episode) }
|
||||||
|
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(session, host) {
|
||||||
|
runCatching {
|
||||||
|
val startup = withContext(Dispatchers.IO) {
|
||||||
|
session.open()
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
// A choice made on this title wins; without one, the choice the
|
||||||
|
// viewer made anywhere else does. Both are the desktop client's
|
||||||
|
// fallback chain, and it is what makes "Japanese with English
|
||||||
|
// subtitles" a thing you say once rather than once per show.
|
||||||
|
val show = engine.titleTrackPreferences(titleKey)
|
||||||
|
val global = engine.playbackPrefs()
|
||||||
|
val watch = engine.watchState(episode.shareId, episode.linkId)
|
||||||
|
Startup(
|
||||||
|
nativeHandle = session.nativeHandle(),
|
||||||
|
size = session.size(),
|
||||||
|
// A finished episode restarts rather than resuming three
|
||||||
|
// seconds from its own credits.
|
||||||
|
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||||
|
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
||||||
|
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
||||||
|
subtitles = show?.subtitles ?: global.subtitles,
|
||||||
|
volume = global.volume,
|
||||||
|
muted = global.muted,
|
||||||
|
speed = global.speed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nativeHost.play(
|
||||||
|
key, startup.nativeHandle, startup.size, startup.startPosition,
|
||||||
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
|
)
|
||||||
|
// After the load, because mpv keeps none of the three across one.
|
||||||
|
nativeHost.setVolume(startup.volume)
|
||||||
|
nativeHost.setMuted(startup.muted)
|
||||||
|
nativeHost.setSpeed(startup.speed)
|
||||||
|
session.transferToPlayer()
|
||||||
|
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||||
|
// this episode's, so detaching it belongs to the effect below and not here.
|
||||||
|
DisposableEffect(session) { onDispose { session.close(nativeHost) } }
|
||||||
|
DisposableEffect(nativeHost) { onDispose { nativeHost.detachSurface() } }
|
||||||
|
|
||||||
|
val state by nativeHost.state.collectAsState()
|
||||||
|
val chapters by nativeHost.chapters.collectAsState()
|
||||||
|
val tracks by nativeHost.tracks.collectAsState()
|
||||||
|
// Read once: a preference the viewer changes mid-episode applies to the
|
||||||
|
// next one, and re-reading the store per frame would not.
|
||||||
|
var autoSkip by remember { mutableStateOf(false) }
|
||||||
|
var autoplay by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||||
|
.onSuccess { autoSkip = it.autoSkip; autoplay = it.autoplayNext }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume position, written only from readings that carry this episode's
|
||||||
|
// name: one host serves every episode, and an unattributed reading is the
|
||||||
|
// previous one's clock.
|
||||||
|
fun save() {
|
||||||
|
val current = nativeHost.state.value
|
||||||
|
if (current.media == key && current.duration > 0.0) {
|
||||||
|
onSaveProgress(
|
||||||
|
episode,
|
||||||
|
current.position.coerceIn(0.0, current.duration),
|
||||||
|
current.duration,
|
||||||
|
// Watched means played to the end. An episode that was stopped,
|
||||||
|
// or that mpv could not play at all, also "ended" — recording
|
||||||
|
// either as watched retires an episode nobody saw.
|
||||||
|
current.endReason == EndReason.Eof ||
|
||||||
|
current.position >= current.duration * WATCHED_FRACTION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(key) {
|
||||||
|
while (true) {
|
||||||
|
delay(5_000)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(key) { onDispose { save() } }
|
||||||
|
|
||||||
|
val next = episodes.getOrNull(index + 1)
|
||||||
|
// Both walks tell the host they are one, so the foreground service holds
|
||||||
|
// through the gap where nothing is loaded — see NativeMpvHost.advancing.
|
||||||
|
fun advance() {
|
||||||
|
save()
|
||||||
|
next?.let { nativeHost.beginTransition(); onIndexChange(index + 1) }
|
||||||
|
}
|
||||||
|
fun retreat() {
|
||||||
|
if (index == 0) return
|
||||||
|
save()
|
||||||
|
nativeHost.beginTransition()
|
||||||
|
onIndexChange(index - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the media notification, the lock screen and Picture-in-Picture show.
|
||||||
|
// None of them can reach this composition, and all three have to name the
|
||||||
|
// episode rather than the app.
|
||||||
|
LaunchedEffect(key, index, episodes.size, title.key) {
|
||||||
|
nativeHost.publish(
|
||||||
|
NowPlaying(
|
||||||
|
media = key,
|
||||||
|
title = episode.label,
|
||||||
|
show = title.canonicalName ?: title.name,
|
||||||
|
detail = episode.detail,
|
||||||
|
artworkUrl = title.posterUrl ?: title.backdropUrl,
|
||||||
|
artworkFile = episode.thumbnailSource,
|
||||||
|
hasPrevious = index > 0,
|
||||||
|
hasNext = next != null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Those same surfaces walk the playlist through here, because this is where
|
||||||
|
// the open episode lives. Both callbacks run on the main thread.
|
||||||
|
DisposableEffect(nativeHost, index, episodes.size) {
|
||||||
|
nativeHost.onSkipNext = { advance() }
|
||||||
|
nativeHost.onSkipPrevious = { retreat() }
|
||||||
|
onDispose {
|
||||||
|
nativeHost.onSkipNext = null
|
||||||
|
nativeHost.onSkipPrevious = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoplay fires on a clean end of file only, and only for this episode's
|
||||||
|
// own reading — never on a failure, and never on the outgoing file's.
|
||||||
|
LaunchedEffect(state.endReason, state.media) {
|
||||||
|
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
|
||||||
|
}
|
||||||
|
// A file mpv could not play ends like any other. Saying so is the whole
|
||||||
|
// difference between a broken episode and an app that silently skipped one.
|
||||||
|
LaunchedEffect(state.endReason, state.media) {
|
||||||
|
if (state.endReason == EndReason.Failed && state.media == key) {
|
||||||
|
playbackError = "This episode could not be played."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What mpv itself complained about, which the play call never sees: by the
|
||||||
|
// time a header turns out to be unreadable it has long since returned.
|
||||||
|
val problem by nativeHost.problem.collectAsState()
|
||||||
|
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
||||||
|
|
||||||
|
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
|
||||||
|
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
||||||
|
}
|
||||||
|
// Auto-skip is offered once per chapter: seeking back into an opening on
|
||||||
|
// purpose must not be undone by the setting that skipped it.
|
||||||
|
val skipped = remember(key) { mutableSetOf<Long>() }
|
||||||
|
LaunchedEffect(offer?.target) {
|
||||||
|
val target = offer?.target ?: return@LaunchedEffect
|
||||||
|
val chapter = chapters.entries.lastOrNull { state.position + 0.001 >= it.start } ?: return@LaunchedEffect
|
||||||
|
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
var landscapeLocked by remember { mutableStateOf(false) }
|
||||||
|
var showControls by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(showControls, state.paused) {
|
||||||
|
if (showControls && !state.paused) {
|
||||||
|
delay(CONTROLS_SECONDS * 1_000)
|
||||||
|
showControls = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Everything this screen draws over the picture is sized for a phone panel
|
||||||
|
// and unreadable in a thumbnail, and Android supplies its own transport
|
||||||
|
// there. Leaving PiP must find the controls up rather than mid-timeout.
|
||||||
|
val overlays = !inPictureInPicture
|
||||||
|
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
|
||||||
|
|
||||||
|
if (minimized) return
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
enabled = overlays,
|
||||||
|
onClick = { showControls = !showControls },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { viewContext ->
|
||||||
|
SurfaceView(viewContext).apply {
|
||||||
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
|
||||||
|
host.attachSurface(holder.surface)
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = overlays && showControls,
|
||||||
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onMinimize) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Leave the video",
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The episode leads and the show follows it: which episode is
|
||||||
|
// the thing a viewer who has just woken the controls is
|
||||||
|
// checking, and the show is what they already know.
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
episode.label,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
|
||||||
|
.joinToString(" • "),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Two ways out, because they mean different things: back leaves
|
||||||
|
// the video and keeps the episode playing, this ends it.
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Stop playback", tint = Color.White)
|
||||||
|
}
|
||||||
|
remaining(state)?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = overlays && showControls,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
PlayerControls(
|
||||||
|
titleKey = titleKey,
|
||||||
|
host = nativeHost,
|
||||||
|
state = state,
|
||||||
|
chapters = chapters.entries,
|
||||||
|
tracks = tracks,
|
||||||
|
hasPrevious = index > 0,
|
||||||
|
hasNext = next != null,
|
||||||
|
onPrevious = { retreat() },
|
||||||
|
onNext = { advance() },
|
||||||
|
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
||||||
|
landscapeLocked = landscapeLocked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn whether or not the rest of the controls are up: an opening lasts
|
||||||
|
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||||
|
// reaches in time.
|
||||||
|
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
|
||||||
|
TonalButton(
|
||||||
|
onClick = { nativeHost.seek(skip.target) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||||
|
) { Text(skip.label) }
|
||||||
|
}
|
||||||
|
|
||||||
|
UpNextCard(
|
||||||
|
next = next,
|
||||||
|
visible = overlays && autoplay && !state.paused &&
|
||||||
|
chapters.creditsStart?.let { state.position >= it } == true,
|
||||||
|
onPlayNow = { advance() },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only while nothing else explains the still picture: an error is the
|
||||||
|
// more useful thing to say, and a spinner over it reads as "still
|
||||||
|
// trying".
|
||||||
|
if (state.stalled && playbackError == null) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||||
|
.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
if (state.buffering) {
|
||||||
|
Text(
|
||||||
|
state.cachePercent.takeIf { it > 0.0 }
|
||||||
|
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackError?.let { message ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer, MaterialTheme.shapes.small)
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(message, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
// Dismissible, because the message is not always fatal — a
|
||||||
|
// subtitle track that failed to load leaves an episode that
|
||||||
|
// plays perfectly well behind an error that used to be permanent.
|
||||||
|
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
||||||
|
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Startup(
|
||||||
|
val nativeHandle: ULong,
|
||||||
|
val size: ULong,
|
||||||
|
val startPosition: Double,
|
||||||
|
val audioLanguage: String?,
|
||||||
|
val subtitleLanguage: String?,
|
||||||
|
val subtitles: Boolean,
|
||||||
|
val volume: Double,
|
||||||
|
val muted: Boolean,
|
||||||
|
val speed: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immersive for as long as this screen is up, and only that long.
|
||||||
|
*
|
||||||
|
* Hiding the bars is not enough on its own: without `setDecorFitsSystemWindows`
|
||||||
|
* and a cutout mode the picture stops at the notch and at the gesture bar, which
|
||||||
|
* is the "not really fullscreen" everyone notices.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ImmersiveWindow(activity: Activity?) {
|
||||||
|
DisposableEffect(activity) {
|
||||||
|
val window = activity?.window
|
||||||
|
if (window == null) return@DisposableEffect onDispose {}
|
||||||
|
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||||
|
val previousCutout = window.attributes.layoutInDisplayCutoutMode
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
window.attributes = window.attributes.also {
|
||||||
|
it.layoutInDisplayCutoutMode =
|
||||||
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
}
|
||||||
|
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
|
controller.systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
onDispose {
|
||||||
|
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||||
|
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
window.attributes = window.attributes.also { it.layoutInDisplayCutoutMode = previousCutout }
|
||||||
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
|
||||||
|
private fun Activity.toggleLandscape(): Boolean {
|
||||||
|
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
requestedOrientation = if (lock) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
else ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
return lock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the transport offers, in two rows.
|
||||||
|
*
|
||||||
|
* A phone in landscape has one thumb's worth of room, so the second row is
|
||||||
|
* icons: the transport cluster on the left, where a thumb already is, and the
|
||||||
|
* pickers on the right. Only the seek bar gets a full row of its own.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PlayerControls(
|
||||||
|
titleKey: String,
|
||||||
|
host: NativeMpvHost,
|
||||||
|
state: MpvPlaybackState,
|
||||||
|
chapters: List<ChapterEntry>,
|
||||||
|
tracks: List<MpvTrack>,
|
||||||
|
hasPrevious: Boolean,
|
||||||
|
hasNext: Boolean,
|
||||||
|
onPrevious: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
onRotate: () -> Unit,
|
||||||
|
landscapeLocked: Boolean,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var choosing by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showChapters by remember { mutableStateOf(false) }
|
||||||
|
var showSpeeds by remember { mutableStateOf(false) }
|
||||||
|
// What the store says, so the dialog opens on the rate that is playing.
|
||||||
|
var speed by remember { mutableStateOf(1.0) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||||
|
.onSuccess { speed = it.speed }
|
||||||
|
}
|
||||||
|
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val roomForVolume = with(density) {
|
||||||
|
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f))
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
// Where the thumb is while a finger is on it. A seek per touch-move
|
||||||
|
// aborts every outstanding prefetch, so a one-second drag used to issue
|
||||||
|
// dozens of seeks and throw away every in-flight block — and the thumb
|
||||||
|
// snapped backwards under the finger, because it was drawn from a
|
||||||
|
// position that only updates four times a second.
|
||||||
|
var scrubbing by remember { mutableStateOf<Float?>(null) }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
formatTime(scrubbing?.toDouble() ?: state.position),
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
Slider(
|
||||||
|
value = scrubbing ?: state.position.toFloat().coerceIn(0f, duration),
|
||||||
|
onValueChange = { scrubbing = it },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
scrubbing?.let { host.seek(it.toDouble()) }
|
||||||
|
scrubbing = null
|
||||||
|
},
|
||||||
|
valueRange = 0f..duration,
|
||||||
|
track = { AccentTrack(it) },
|
||||||
|
)
|
||||||
|
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
|
||||||
|
}
|
||||||
|
Text(formatTime(state.duration), color = Color.White, style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CompactIcon(Icons.Default.SkipPrevious, "Previous episode", enabled = hasPrevious, onClick = onPrevious)
|
||||||
|
CompactIcon(Icons.Default.Replay10, "Back ten seconds") { host.seek(state.position - 10) }
|
||||||
|
IconButton(onClick = { host.setPaused(!state.paused) }) {
|
||||||
|
Icon(
|
||||||
|
if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||||
|
contentDescription = if (state.paused) "Play" else "Pause",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompactIcon(Icons.Default.Forward30, "Forward thirty seconds") { host.seek(state.position + 30) }
|
||||||
|
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (chapters.size > 1) {
|
||||||
|
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||||
|
}
|
||||||
|
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||||
|
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||||
|
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||||
|
CompactIcon(
|
||||||
|
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||||
|
"Mute",
|
||||||
|
) {
|
||||||
|
host.setMuted(!state.muted)
|
||||||
|
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
||||||
|
}
|
||||||
|
// The app's own volume, which is not the device's: a file
|
||||||
|
// mastered quiet is turned up here and stays up for the next
|
||||||
|
// one. Only where there is room for it — a phone in portrait
|
||||||
|
// has the system volume keys and no width to spare.
|
||||||
|
if (roomForVolume) {
|
||||||
|
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||||
|
Slider(
|
||||||
|
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||||
|
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
val chosen = dragging?.toDouble()
|
||||||
|
dragging = null
|
||||||
|
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||||
|
},
|
||||||
|
valueRange = 0f..MAX_VOLUME,
|
||||||
|
modifier = Modifier.width(96.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompactIcon(
|
||||||
|
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||||
|
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||||
|
onClick = onRotate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSpeeds) {
|
||||||
|
SpeedChooser(
|
||||||
|
current = speed,
|
||||||
|
onDismiss = { showSpeeds = false },
|
||||||
|
onSelect = { chosen ->
|
||||||
|
speed = chosen
|
||||||
|
host.setSpeed(chosen)
|
||||||
|
showSpeeds = false
|
||||||
|
scope.launch(Dispatchers.IO) { rememberSpeed(chosen) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showChapters) {
|
||||||
|
ChapterChooser(
|
||||||
|
chapters = chapters,
|
||||||
|
position = state.position,
|
||||||
|
onDismiss = { showChapters = false },
|
||||||
|
onSelect = { host.seek(it.start); showChapters = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
choosing?.let { type ->
|
||||||
|
TrackChooser(
|
||||||
|
type = type,
|
||||||
|
tracks = tracks.filter { it.type == type },
|
||||||
|
onDismiss = { choosing = null },
|
||||||
|
onSelect = { selected ->
|
||||||
|
host.selectTrack(selected)
|
||||||
|
choosing = null
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
// No per-title choice yet: start from what plays everywhere
|
||||||
|
// else, so choosing an audio track does not silently drop
|
||||||
|
// the subtitle preference this episode started with.
|
||||||
|
val global = engine.playbackPrefs()
|
||||||
|
val old = engine.titleTrackPreferences(titleKey) ?: TrackPreferencesRecord(
|
||||||
|
audioLanguage = global.audioLanguage,
|
||||||
|
subtitleLanguage = global.subtitleLanguage,
|
||||||
|
subtitles = global.subtitles,
|
||||||
|
)
|
||||||
|
engine.setTitleTrackPreferences(
|
||||||
|
titleKey,
|
||||||
|
if (type == "audio") {
|
||||||
|
old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
|
||||||
|
} else {
|
||||||
|
// Turning subtitles off keeps the language it was
|
||||||
|
// off *from*, so turning them back on does not have
|
||||||
|
// to be told the language again — as on desktop.
|
||||||
|
old.copy(
|
||||||
|
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank)
|
||||||
|
?: old.subtitleLanguage,
|
||||||
|
subtitles = selected != null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a volume or a mute, leaving whichever was not given as it was.
|
||||||
|
*
|
||||||
|
* Written when the thumb is released rather than while it moves: a drag emits
|
||||||
|
* dozens of values a second and every one of them would be a file rewrite. A
|
||||||
|
* failure is dropped — the volume is already applied, and the cost of not
|
||||||
|
* storing it is that the next launch starts where the last one was stored.
|
||||||
|
*/
|
||||||
|
private suspend fun rememberVolume(volume: Double? = null, muted: Boolean? = null) {
|
||||||
|
runCatching {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
val prefs = engine.playbackPrefs()
|
||||||
|
engine.setPlaybackPrefs(prefs.copy(volume = volume ?: prefs.volume, muted = muted ?: prefs.muted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a playback rate, so the next episode starts at it. */
|
||||||
|
private suspend fun rememberSpeed(speed: Double) {
|
||||||
|
runCatching {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
engine.setPlaybackPrefs(engine.playbackPrefs().copy(speed = speed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rates worth offering.
|
||||||
|
*
|
||||||
|
* A short list rather than a slider: the useful rates are a handful of ratios,
|
||||||
|
* and a slider on a phone lands on 1.07 as often as on 1.0.
|
||||||
|
*/
|
||||||
|
private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Double) -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Playback speed") },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
SPEEDS.forEach { rate ->
|
||||||
|
EdgedButton(
|
||||||
|
onClick = { onSelect(rate) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
(if (kotlin.math.abs(rate - current) < 0.001) "✓ " else "") +
|
||||||
|
if (rate == 1.0) "Normal" else "${rate}×",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where each chapter begins, ticked onto the seek bar. */
|
||||||
|
@Composable
|
||||||
|
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
|
||||||
|
if (chapters.size < 2 || duration <= 0.0) return
|
||||||
|
Canvas(modifier.padding(horizontal = 10.dp)) {
|
||||||
|
val width = 2.dp.toPx()
|
||||||
|
val height = 8.dp.toPx()
|
||||||
|
chapters.drop(1).forEach { chapter ->
|
||||||
|
val fraction = (chapter.start / duration).coerceIn(0.0, 1.0).toFloat()
|
||||||
|
drawRect(
|
||||||
|
color = Color.White.copy(alpha = 0.75f),
|
||||||
|
topLeft = Offset(fraction * size.width - width / 2, (size.height - height) / 2),
|
||||||
|
size = Size(width, height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChapterChooser(
|
||||||
|
chapters: List<ChapterEntry>,
|
||||||
|
position: Double,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (ChapterEntry) -> Unit,
|
||||||
|
) {
|
||||||
|
val current = chapters.indexOfLast { position + 0.001 >= it.start }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Chapters") },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
||||||
|
items(chapters, key = ChapterEntry::index) { chapter ->
|
||||||
|
EdgedButton(
|
||||||
|
onClick = { onSelect(chapter) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
(if (chapters.getOrNull(current) === chapter) "✓ " else "") +
|
||||||
|
"${formatTime(chapter.start)} ${chapter.label}",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The countdown into the next episode, shown once the credits run starts.
|
||||||
|
*
|
||||||
|
* "Watch till the end" holds for the rest of the file: someone who wants the
|
||||||
|
* post-credits scene should have to say so once, not once every ten seconds.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpNextCard(
|
||||||
|
next: EpisodeRecord?,
|
||||||
|
visible: Boolean,
|
||||||
|
onPlayNow: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var held by remember(next?.linkId) { mutableStateOf(false) }
|
||||||
|
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||||
|
val upNext = next
|
||||||
|
val showing = visible && upNext != null && !held
|
||||||
|
LaunchedEffect(showing) {
|
||||||
|
if (!showing) return@LaunchedEffect
|
||||||
|
remaining = UP_NEXT_SECONDS
|
||||||
|
while (remaining > 0) {
|
||||||
|
delay(1_000)
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
onPlayNow()
|
||||||
|
}
|
||||||
|
// `showing` already implies a non-null episode, and the compiler knows it.
|
||||||
|
if (!showing) return
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Up next in ${remaining}s",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
upNext.label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
AccentButton(onClick = onPlayNow) { Text("Play now") }
|
||||||
|
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackChooser(
|
||||||
|
type: String,
|
||||||
|
tracks: List<MpvTrack>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (MpvTrack?) -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
||||||
|
if (type == "sub") {
|
||||||
|
item {
|
||||||
|
EdgedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
|
||||||
|
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(tracks, key = MpvTrack::id) { track ->
|
||||||
|
EdgedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
|
||||||
|
Text(if (track.selected) "✓ ${track.label}" else track.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompactIcon(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
description: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = description,
|
||||||
|
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ((Double) -> Unit).asFloat(): (Float) -> Unit = { this(it.toDouble()) }
|
||||||
|
|
||||||
|
/** How much of the episode is left, or nothing before the duration is known. */
|
||||||
|
private fun remaining(state: MpvPlaybackState): String? = (state.duration - state.position)
|
||||||
|
.takeIf { state.duration > 0.0 && it >= 1.0 }
|
||||||
|
?.let { "${formatTime(it)} left" }
|
||||||
|
|
||||||
|
private fun formatTime(seconds: Double): String {
|
||||||
|
val total = seconds.coerceAtLeast(0.0).roundToInt()
|
||||||
|
val hours = total / 3_600
|
||||||
|
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
|
||||||
|
else "%d:%02d".format(total / 60, total % 60)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
|
import android.view.Surface
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.AndroidEngine
|
||||||
|
import uniffi.pstr_android.AndroidStream
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
|
||||||
|
class RustStreamSession(
|
||||||
|
private val episode: EpisodeRecord,
|
||||||
|
) {
|
||||||
|
private var engine: AndroidEngine? = null
|
||||||
|
private var stream: AndroidStream? = null
|
||||||
|
private var handle: ULong? = null
|
||||||
|
private var streamSize: ULong? = null
|
||||||
|
|
||||||
|
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
|
||||||
|
val openedEngine = NativeRuntime.engine()
|
||||||
|
engine = openedEngine
|
||||||
|
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
|
||||||
|
streamSize = it.size()
|
||||||
|
handle = it.nativeHandle()
|
||||||
|
}
|
||||||
|
this@RustStreamSession
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
|
||||||
|
|
||||||
|
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
|
||||||
|
|
||||||
|
fun releaseNative(host: LibmpvHost) {
|
||||||
|
handle?.let(host::releaseNativeStream)
|
||||||
|
handle = null
|
||||||
|
streamSize = null
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native player now owns the published token until stop/end/close. */
|
||||||
|
fun transferToPlayer() {
|
||||||
|
handle = null
|
||||||
|
streamSize = null
|
||||||
|
stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the episode's stream back to Rust, from a scope of this session's own.
|
||||||
|
*
|
||||||
|
* Deliberately not the caller's: the one moment this has to run is when the
|
||||||
|
* player leaves the composition, which is the same moment a
|
||||||
|
* `rememberCoroutineScope` is cancelled — so a release launched there is
|
||||||
|
* cancelled before it starts, and the stream is held for the life of the
|
||||||
|
* process. Once per episode played, that is every episode.
|
||||||
|
*/
|
||||||
|
fun close(host: LibmpvHost) {
|
||||||
|
releaseNative(host)
|
||||||
|
sessions.launch {
|
||||||
|
runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||||
|
engine = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where stream teardown runs: process-lifetime, like the engine it releases to,
|
||||||
|
* and never cancelled by a screen going away.
|
||||||
|
*/
|
||||||
|
private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
|
||||||
|
interface LibmpvHost {
|
||||||
|
fun attachSurface(surface: Surface)
|
||||||
|
fun detachSurface()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes the opaque handle through pstr_android_stream_{read,size}.
|
||||||
|
*
|
||||||
|
* [media] names the episode being loaded. One host serves every episode in
|
||||||
|
* turn, so its readings have to say which one they describe — see
|
||||||
|
* [MpvPlaybackState.media].
|
||||||
|
*/
|
||||||
|
suspend fun play(
|
||||||
|
media: String,
|
||||||
|
nativeHandle: ULong,
|
||||||
|
size: ULong,
|
||||||
|
startPosition: Double = 0.0,
|
||||||
|
audioLanguage: String? = null,
|
||||||
|
subtitleLanguage: String? = null,
|
||||||
|
subtitles: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||||
|
fun releaseNativeStream(nativeHandle: ULong)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How an episode is named in watch state, work names and player readings. */
|
||||||
|
fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
|
||||||
@@ -2,6 +2,13 @@ package io.narl.protonstream.settings
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings that are Android's alone.
|
||||||
|
*
|
||||||
|
* Playback preferences — volume, languages, autoplay, auto-skip — deliberately
|
||||||
|
* do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
|
||||||
|
* there is one definition of them rather than one per front end.
|
||||||
|
*/
|
||||||
class SettingsStore(context: Context) {
|
class SettingsStore(context: Context) {
|
||||||
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ import io.narl.protonstream.native.NativeRuntime
|
|||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.OfflineRecord
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.MatchSummary
|
||||||
import uniffi.pstr_android.MetadataProvider
|
import uniffi.pstr_android.MetadataProvider
|
||||||
import uniffi.pstr_android.MetadataSettingsRecord
|
import uniffi.pstr_android.MetadataSettingsRecord
|
||||||
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
@@ -44,6 +47,7 @@ data class AppUiState(
|
|||||||
language = "en",
|
language = "en",
|
||||||
ready = true,
|
ready = true,
|
||||||
),
|
),
|
||||||
|
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +59,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
if (!NativeRuntime.tlsReady) {
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
reload()
|
reload()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||||
@@ -83,6 +92,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recrawl one share rather than the whole library.
|
||||||
|
*
|
||||||
|
* A viewer who has just added files to one link should not have to pay for
|
||||||
|
* a walk of every other one — and a share whose link has expired makes a
|
||||||
|
* whole-library refresh fail, which used to leave no way to refresh the
|
||||||
|
* shares that still work.
|
||||||
|
*/
|
||||||
|
fun refreshShare(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||||
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
mutableState.update { it.copy(refreshing = false) }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run enrichment again.
|
||||||
|
*
|
||||||
|
* With [force] every title is looked up afresh, including ones that already
|
||||||
|
* matched — the way out of a library where a provider's early answers were
|
||||||
|
* wrong, or where a match was made against a provider that has since been
|
||||||
|
* changed.
|
||||||
|
*/
|
||||||
|
fun matchTitles(force: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||||
|
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
||||||
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
mutableState.update { it.copy(refreshing = false) }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun addShare(name: String, url: String, password: String?) {
|
fun addShare(name: String, url: String, password: String?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -96,6 +142,28 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-supply the link behind a share whose stored secret cannot be read.
|
||||||
|
*
|
||||||
|
* The Keystore key is invalidated by a lockscreen change or a device
|
||||||
|
* restore, and after that every open of every share fails — including the
|
||||||
|
* read inside "remove this share", so the obvious way out is closed too.
|
||||||
|
* Re-entering the link rewrites the secret and leaves the catalog and the
|
||||||
|
* offline files alone.
|
||||||
|
*/
|
||||||
|
fun repairShare(id: String, url: String, password: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
|
||||||
|
engine.crawl(id)
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeShare(id: String) {
|
fun removeShare(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -132,8 +200,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
engine.setMetadataApiKey(provider, apiKey)
|
engine.setMetadataApiKey(provider, apiKey)
|
||||||
}
|
}
|
||||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
if (enabled) engine.matchTitles(false)
|
if (enabled) engine.matchTitles(false) else null
|
||||||
}
|
}
|
||||||
|
}.onSuccess { summary ->
|
||||||
|
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
||||||
}.onFailure(::reportError)
|
}.onFailure(::reportError)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
@@ -141,6 +211,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun reloadAfterMetadataChange() = reload()
|
fun reloadAfterMetadataChange() = reload()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an enrichment pass did, in one line.
|
||||||
|
*
|
||||||
|
* Failures are reported even when most titles matched: a run that quietly
|
||||||
|
* dropped a third of the library is the case this counting exists for.
|
||||||
|
*/
|
||||||
|
private fun describe(summary: MatchSummary): String {
|
||||||
|
if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
|
||||||
|
return "Everything is already matched"
|
||||||
|
}
|
||||||
|
val parts = mutableListOf("${summary.matched} matched")
|
||||||
|
if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
|
||||||
|
if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
|
||||||
|
if (summary.failed > 0u) parts += "${summary.failed} failed"
|
||||||
|
return parts.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
fun removeOffline(file: OfflineRecord) {
|
fun removeOffline(file: OfflineRecord) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -170,16 +257,96 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist where an episode was left.
|
||||||
|
*
|
||||||
|
* The player calls this while it is still up and once more as it leaves, so
|
||||||
|
* it runs on the ViewModel's scope rather than the screen's — a save issued
|
||||||
|
* on the way out must not be cancelled by the screen going away.
|
||||||
|
*/
|
||||||
|
fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().saveWatchState(
|
||||||
|
episode.shareId,
|
||||||
|
episode.linkId,
|
||||||
|
position.coerceIn(0.0, duration),
|
||||||
|
duration,
|
||||||
|
watched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an episode seen, or unseen, without playing it.
|
||||||
|
*
|
||||||
|
* Unwatching rewinds, matching the desktop client: an episode marked unseen
|
||||||
|
* that kept its position would come straight back as "resume at 19:04",
|
||||||
|
* which is not what the viewer asked for. Marking one seen puts the position
|
||||||
|
* at its duration where one is known, so the progress bar agrees with the
|
||||||
|
* tick.
|
||||||
|
*/
|
||||||
|
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
||||||
|
engine.saveWatchState(
|
||||||
|
episode.shareId,
|
||||||
|
episode.linkId,
|
||||||
|
if (watched) duration ?: 0.0 else 0.0,
|
||||||
|
duration,
|
||||||
|
watched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAllOffline() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
|
||||||
|
NativeRuntime.engine().removeAllOffline()
|
||||||
|
DownloadStateStore(appContext).clear()
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBlockCache() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
|
||||||
|
}.onSuccess { reclaimed ->
|
||||||
|
mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
|
// Here rather than inside `library()`, which is read-only
|
||||||
|
// and cached: a full reload is the one place that has
|
||||||
|
// already paid for a walk of the offline files.
|
||||||
|
engine.pruneOfflineFiles()
|
||||||
Reloaded(
|
Reloaded(
|
||||||
engine.shares(),
|
engine.shares(),
|
||||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
||||||
engine.offlineFiles(),
|
engine.offlineFiles(),
|
||||||
engine.metadataSettings(),
|
engine.metadataSettings(),
|
||||||
|
engine.storageUsage(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { result ->
|
}.onSuccess { result ->
|
||||||
@@ -189,6 +356,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
titles = result.titles,
|
titles = result.titles,
|
||||||
offline = result.offline,
|
offline = result.offline,
|
||||||
metadataSettings = result.metadataSettings,
|
metadataSettings = result.metadataSettings,
|
||||||
|
storage = result.storage,
|
||||||
) }
|
) }
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
@@ -215,6 +383,7 @@ private data class Reloaded(
|
|||||||
val titles: List<TitleRecord>,
|
val titles: List<TitleRecord>,
|
||||||
val offline: List<OfflineRecord>,
|
val offline: List<OfflineRecord>,
|
||||||
val metadataSettings: MetadataSettingsRecord,
|
val metadataSettings: MetadataSettingsRecord,
|
||||||
|
val storage: StorageUsageRecord,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
|
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -19,50 +22,61 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
/** HTTPS-only artwork loader backed by the app-private cache directory. */
|
/**
|
||||||
|
* One file to pull Proton's own thumbnail from when no provider poster exists.
|
||||||
|
*
|
||||||
|
* Proton renders a thumbnail per file, so any episode of a show is a frame of
|
||||||
|
* the right show — the desktop client picks the first for the same reason.
|
||||||
|
*/
|
||||||
|
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
|
||||||
|
val key: String get() = "$shareId/$linkId"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the first episode of a title offers as a poster.
|
||||||
|
*
|
||||||
|
* Metadata lookups are off by default, so for a freshly crawled library this is
|
||||||
|
* the only artwork there is.
|
||||||
|
*/
|
||||||
|
internal val TitleRecord.thumbnailSource: ThumbnailSource?
|
||||||
|
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
|
||||||
|
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
|
||||||
|
|
||||||
|
/** What one episode offers as a still. */
|
||||||
|
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
||||||
|
get() = ThumbnailSource(shareId, volumeId, linkId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTPS-only artwork loader backed by the app-private cache directory.
|
||||||
|
*
|
||||||
|
* [fallback] is used only when there is no provider artwork or it fails to
|
||||||
|
* load, which — with metadata off — is the common case rather than the rare
|
||||||
|
* one.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) {
|
internal fun RemoteArtwork(
|
||||||
|
url: String?,
|
||||||
|
description: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fallback: ThumbnailSource? = null,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
LaunchedEffect(url) {
|
LaunchedEffect(url, fallback) {
|
||||||
image = url?.let { requested ->
|
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val uri = URI(requested)
|
|
||||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
|
||||||
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
||||||
val cached = directory.resolve("$digest.img")
|
|
||||||
val bytes = if (cached.isFile) cached.readBytes() else {
|
|
||||||
val connection = uri.toURL().openConnection() as HttpURLConnection
|
|
||||||
connection.connectTimeout = 10_000
|
|
||||||
connection.readTimeout = 10_000
|
|
||||||
connection.instanceFollowRedirects = true
|
|
||||||
val status = connection.responseCode
|
|
||||||
require(status in 200..299) { "Artwork request failed ($status)" }
|
|
||||||
require(connection.url.protocol.equals("https", ignoreCase = true)) {
|
|
||||||
"Artwork redirect must use HTTPS"
|
|
||||||
}
|
|
||||||
connection.inputStream.use(::readBounded).also { downloaded ->
|
|
||||||
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
|
|
||||||
val temporary = directory.resolve("$digest.part")
|
|
||||||
temporary.writeBytes(downloaded)
|
|
||||||
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
||||||
image?.let {
|
image?.let {
|
||||||
@@ -71,7 +85,153 @@ internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||||
|
*
|
||||||
|
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
|
||||||
|
* it from a service with no composition of its own — but it wants exactly this
|
||||||
|
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||||
|
* shows costs no second download.
|
||||||
|
*/
|
||||||
|
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
||||||
|
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val uri = URI(requested)
|
||||||
|
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||||
|
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||||
|
val cached = directory.resolve("$digest.img")
|
||||||
|
val fresh = !cached.isFile
|
||||||
|
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||||
|
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
|
||||||
|
if (fresh) {
|
||||||
|
// Written only once it has decoded. Caching the bytes first is
|
||||||
|
// what turns one hostile poster into a crash on every launch
|
||||||
|
// that survives until app data is cleared.
|
||||||
|
val temporary = directory.resolve("$digest.part")
|
||||||
|
temporary.writeBytes(bytes)
|
||||||
|
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
|
||||||
|
prune(directory)
|
||||||
|
} else {
|
||||||
|
cached.setLastModified(System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
bitmap
|
||||||
|
}.getOrNull()?.also { decoded.put(requested, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proton's own thumbnail for one file, decoded.
|
||||||
|
*
|
||||||
|
* The bridge holds the disk cache, so what is worth keeping here is the decoded
|
||||||
|
* bitmap and the *absence* of one: a file with no thumbnail is common, and
|
||||||
|
* without remembering that, every scroll back over the grid pays a round trip
|
||||||
|
* per tile to be told so again. A failed call is not remembered — an unreachable
|
||||||
|
* share or a session that has to be reopened is transient, and the next
|
||||||
|
* composition should try again.
|
||||||
|
*/
|
||||||
|
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
|
||||||
|
val file = source ?: return null
|
||||||
|
val key = file.key
|
||||||
|
decoded.get(key)?.let { return it }
|
||||||
|
if (key in missing) return null
|
||||||
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||||
|
}
|
||||||
|
val bytes = fetched.getOrElse { return null }
|
||||||
|
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
|
||||||
|
if (bitmap == null) {
|
||||||
|
missing.add(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
decoded.put(key, bitmap)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(uri: URI): ByteArray {
|
||||||
|
val connection = uri.toURL().openConnection() as HttpURLConnection
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 10_000
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
val status = connection.responseCode
|
||||||
|
require(status in 200..299) { "Artwork request failed ($status)" }
|
||||||
|
require(connection.url.protocol.equals("https", ignoreCase = true)) {
|
||||||
|
"Artwork redirect must use HTTPS"
|
||||||
|
}
|
||||||
|
return connection.inputStream.use(::readBounded)
|
||||||
|
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode within a bound the display can actually use.
|
||||||
|
*
|
||||||
|
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||||
|
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||||
|
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||||
|
* costs no allocation and settles a sample size before anything is committed.
|
||||||
|
*/
|
||||||
|
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||||
|
if (longest <= 0) return null
|
||||||
|
var sample = 1
|
||||||
|
while (longest / sample > MAX_ART_EDGE) sample *= 2
|
||||||
|
return BitmapFactory.decodeByteArray(
|
||||||
|
bytes,
|
||||||
|
0,
|
||||||
|
bytes.size,
|
||||||
|
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||||
|
*
|
||||||
|
* `cacheDir` is reclaimable by the system, but only under storage pressure and
|
||||||
|
* only wholesale — a poster cache that grows with every title ever browsed is
|
||||||
|
* still the app's own doing.
|
||||||
|
*/
|
||||||
|
private fun prune(directory: File) {
|
||||||
|
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
|
||||||
|
var total = files.sumOf(File::length)
|
||||||
|
for (file in files) {
|
||||||
|
if (total <= MAX_ART_CACHE_BYTES) return
|
||||||
|
total -= file.length()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files known to have no Proton thumbnail, so the answer is paid for once.
|
||||||
|
*
|
||||||
|
* Deliberately not persisted: a re-upload can add one, and the cost of being
|
||||||
|
* wrong for a session is a placeholder that a restart clears.
|
||||||
|
*/
|
||||||
|
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
|
||||||
|
*
|
||||||
|
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
|
||||||
|
* cannot collide, since one is always an `https:` URL.
|
||||||
|
*
|
||||||
|
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
|
||||||
|
* recreates items as they leave the viewport — without this, every scroll back
|
||||||
|
* is a full-resolution decode per item.
|
||||||
|
*/
|
||||||
|
private val decoded = object : LruCache<String, Bitmap>(
|
||||||
|
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
|
||||||
|
) {
|
||||||
|
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||||
|
}
|
||||||
|
|
||||||
private const val MAX_ART_BYTES = 12 * 1024 * 1024
|
private const val MAX_ART_BYTES = 12 * 1024 * 1024
|
||||||
|
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
|
||||||
|
|
||||||
|
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
|
||||||
|
private const val MAX_ART_EDGE = 1_024
|
||||||
|
|
||||||
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
|
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
|
||||||
private fun readBounded(input: InputStream): ByteArray {
|
private fun readBounded(input: InputStream): ByteArray {
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
|
import androidx.compose.material3.NavigationRailItemDefaults
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||||
|
import androidx.compose.material3.SliderState
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accent, as a gradient when there is one to draw.
|
||||||
|
*
|
||||||
|
* The desktop paints the accent as a two-hue ramp on everything that is a
|
||||||
|
* control — the seek bar, the primary buttons, the tile progress bar — and the
|
||||||
|
* viewer's "paint the accent as a gradient" toggle is what turns it off. That
|
||||||
|
* toggle was already stored, already offered on this screen, and drawn nowhere
|
||||||
|
* on Android, so the same stored setting meant two different things depending
|
||||||
|
* on which client read it.
|
||||||
|
*
|
||||||
|
* Nothing here reads the toggle. It does not need to: when gradients are off,
|
||||||
|
* `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses
|
||||||
|
* to a flat fill on its own. `primary` and `secondary` are those two colours —
|
||||||
|
* see [schemeOf] — which is why this asks Material for them rather than
|
||||||
|
* reaching past it for the palette.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun accentBrush(): Brush {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
return remember(scheme.primary, scheme.secondary) {
|
||||||
|
Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The primary button: the accent ramp, at the shared corner radius.
|
||||||
|
*
|
||||||
|
* Material's filled button has its own shape token (`CornerFull`) that does not
|
||||||
|
* read `MaterialTheme.shapes`, so the radius has to be handed to it — which is
|
||||||
|
* the whole reason the plain buttons below exist as well. They add nothing but
|
||||||
|
* that.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AccentButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val shape = MaterialTheme.shapes.small
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
// Only while it is live: a disabled button keeps Material's flat
|
||||||
|
// disabled container, because a greyed control that still wears the
|
||||||
|
// one strong colour in the window reads as pressable.
|
||||||
|
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = shape,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TonalButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) = FilledTonalButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EdgedButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) = OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun QuietButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) = TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far along something is, drawn in the accent.
|
||||||
|
*
|
||||||
|
* Hand-drawn rather than `LinearProgressIndicator` because that one takes a
|
||||||
|
* `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop
|
||||||
|
* indicator either side of the bar — detail at a size where the desktop draws a
|
||||||
|
* plain bar. [fill] is a parameter for the one bar that is deliberately not the
|
||||||
|
* accent: a download in flight sits directly under the watch-progress bar of
|
||||||
|
* the same row, and two ramps in the same colour stacked two pixels apart say
|
||||||
|
* nothing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AccentProgress(
|
||||||
|
progress: () -> Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fill: Brush? = null,
|
||||||
|
) {
|
||||||
|
val brush = fill ?: accentBrush()
|
||||||
|
val track = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) {
|
||||||
|
val radius = CornerRadius(size.height / 2)
|
||||||
|
drawRoundRect(color = track, cornerRadius = radius)
|
||||||
|
val done = size.width * progress().coerceIn(0f, 1f)
|
||||||
|
// Below one bar's width there is nothing to round, and a rounded rect
|
||||||
|
// narrower than its own corners draws as a wrong-looking pill.
|
||||||
|
if (done >= size.height) {
|
||||||
|
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seek bar's track, in the accent ramp.
|
||||||
|
*
|
||||||
|
* Passed to `Slider`'s `track` slot, so the thumb, the gestures and the
|
||||||
|
* accessibility semantics stay Material's — this replaces the two rectangles it
|
||||||
|
* would have drawn and nothing else.
|
||||||
|
*
|
||||||
|
* The spent side is the accent and the rest is white at a third: the seek bar
|
||||||
|
* is the one accent surface that sits over arbitrary film footage, where a
|
||||||
|
* palette colour for the *unplayed* remainder would be a coloured line over a
|
||||||
|
* picture rather than the absence of one.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
|
||||||
|
val brush = accentBrush()
|
||||||
|
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
||||||
|
val radius = CornerRadius(size.height / 2)
|
||||||
|
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
||||||
|
val done = size.width * state.coercedValueAsFraction
|
||||||
|
if (done >= size.height) {
|
||||||
|
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The navigation pill, in the accent.
|
||||||
|
*
|
||||||
|
* Solid rather than a ramp, and this is the one place the two clients cannot
|
||||||
|
* agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`,
|
||||||
|
* with no slot to draw into, so a gradient here would mean reimplementing the
|
||||||
|
* bar. The accent itself is still the right answer — the desktop's rule is that
|
||||||
|
* the accent marks the thing you are on — and it is nearer to that than
|
||||||
|
* `secondaryContainer`, which is the accent taken most of the way back to the
|
||||||
|
* page.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun accentNavigationColors(): NavigationSuiteItemColors {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
return NavigationSuiteDefaults.itemColors(
|
||||||
|
navigationBarItemColors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = scheme.onPrimary,
|
||||||
|
selectedTextColor = scheme.onSurface,
|
||||||
|
indicatorColor = scheme.primary,
|
||||||
|
),
|
||||||
|
navigationRailItemColors = NavigationRailItemDefaults.colors(
|
||||||
|
selectedIconColor = scheme.onPrimary,
|
||||||
|
selectedTextColor = scheme.onSurface,
|
||||||
|
indicatorColor = scheme.primary,
|
||||||
|
),
|
||||||
|
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = scheme.primary,
|
||||||
|
selectedIconColor = scheme.onPrimary,
|
||||||
|
selectedTextColor = scheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A solid fill, for the callers of [AccentProgress] that want one colour. */
|
||||||
|
internal fun solid(color: Color): Brush = SolidColor(color)
|
||||||
|
|
||||||
|
/** Thin enough to sit under a row without becoming part of it. */
|
||||||
|
private const val PROGRESS_HEIGHT = 4f
|
||||||
|
|
||||||
|
/** The seek bar is dragged with a finger, so it is the thicker of the two. */
|
||||||
|
private const val TRACK_HEIGHT = 6f
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One corner radius, which is the desktop client's corner radius.
|
||||||
|
*
|
||||||
|
* `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab
|
||||||
|
* pill, the progress bars — and only a badge over artwork is tighter, at 4.
|
||||||
|
* Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen
|
||||||
|
* puts a 28dp bottom sheet next to a 12dp card next to a fully-round button,
|
||||||
|
* and none of those is the shape the other client draws.
|
||||||
|
*
|
||||||
|
* Flat rather than a ramp, therefore, with the two ends kept for the two cases
|
||||||
|
* where a single radius is wrong: `extraSmall` for something small enough that
|
||||||
|
* 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of
|
||||||
|
* the screen and need to read as a lifted sheet rather than a seam.
|
||||||
|
*/
|
||||||
|
internal val ProtonShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(CORNER_TIGHT.dp),
|
||||||
|
small = RoundedCornerShape(CORNER.dp),
|
||||||
|
medium = RoundedCornerShape(CORNER.dp),
|
||||||
|
large = RoundedCornerShape(CORNER.dp),
|
||||||
|
extraLarge = RoundedCornerShape(CORNER_SHEET.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The radius `pstr-app` draws everything at. */
|
||||||
|
internal const val CORNER = 8f
|
||||||
|
|
||||||
|
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
|
||||||
|
internal const val CORNER_TIGHT = 4f
|
||||||
|
|
||||||
|
/** A bottom sheet, which has no desktop counterpart. */
|
||||||
|
internal const val CORNER_SHEET = 16f
|
||||||
@@ -1,24 +1,216 @@
|
|||||||
package io.narl.protonstream.ui.theme
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
private val ProtonColors = darkColorScheme(
|
/**
|
||||||
primary = Color(0xFFCBA6F7),
|
* The palette in force, shared across every composition.
|
||||||
onPrimary = Color(0xFF1E1E2E),
|
*
|
||||||
secondary = Color(0xFFF5C2E7),
|
* The choice is stored by Rust and resolved by Rust — the same flavours, the
|
||||||
background = Color(0xFF1E1E2E),
|
* same accent pairings and the same contrast rule the desktop client uses — so
|
||||||
surface = Color(0xFF181825),
|
* what lives here is only the resolved answer and a way to replace it when the
|
||||||
surfaceVariant = Color(0xFF313244),
|
* viewer picks something else.
|
||||||
onBackground = Color(0xFFCDD6F4),
|
*/
|
||||||
onSurface = Color(0xFFCDD6F4),
|
object AppearanceState {
|
||||||
outline = Color(0xFF6C7086),
|
private val current = MutableStateFlow<PaletteRecord?>(null)
|
||||||
error = Color(0xFFF38BA8),
|
val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
|
||||||
)
|
|
||||||
|
/**
|
||||||
|
* The palette read before the first frame, from
|
||||||
|
* [io.narl.protonstream.native.NativeRuntime.storedPalette].
|
||||||
|
*
|
||||||
|
* Separate from [load] because it runs on the main thread during
|
||||||
|
* `onCreate`: it is the only read that is early enough to decide what the
|
||||||
|
* first composition is painted in, and it is cheap enough to be allowed
|
||||||
|
* there.
|
||||||
|
*/
|
||||||
|
fun seed(palette: PaletteRecord) {
|
||||||
|
current.value = palette
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the stored choice through the engine. Safe to call repeatedly; the
|
||||||
|
* last read wins.
|
||||||
|
*
|
||||||
|
* A no-op once [seed] has run — it resolves the same file through the same
|
||||||
|
* Rust, so the only thing a second read can do is build the engine earlier
|
||||||
|
* than the first screen needed it.
|
||||||
|
*/
|
||||||
|
suspend fun load() {
|
||||||
|
if (current.value != null) return
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||||
|
.onSuccess { current.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaint immediately, before the write that stores the choice lands. */
|
||||||
|
fun apply(palette: PaletteRecord) {
|
||||||
|
current.value = palette
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
||||||
MaterialTheme(colorScheme = ProtonColors, content = content)
|
val palette by AppearanceState.palette.collectAsState()
|
||||||
|
// Before the first read, and if it fails, the palette this app shipped
|
||||||
|
// with — a theme file that cannot be read must never be a reason the
|
||||||
|
// library does not open.
|
||||||
|
LaunchedEffect(Unit) { AppearanceState.load() }
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = schemeOf(palette ?: ProtonFallback),
|
||||||
|
// The other two thirds of what the clients share. Colour was already
|
||||||
|
// one resolved answer for both; type and shape were Material's defaults
|
||||||
|
// here and named constants there, which is most of why the two read as
|
||||||
|
// different apps even where the colours agreed.
|
||||||
|
typography = ProtonTypography,
|
||||||
|
shapes = ProtonShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One resolved palette as Material's roles — **all** of them.
|
||||||
|
*
|
||||||
|
* Every role Material names has to be answered here, because a role left out
|
||||||
|
* does not fall back to something neutral: it keeps the value
|
||||||
|
* `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own
|
||||||
|
* baseline purple-and-grey and belongs to no flavour this app ships.
|
||||||
|
*
|
||||||
|
* That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with
|
||||||
|
* (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in
|
||||||
|
* the library grid, every episode row, every share and every download was drawn
|
||||||
|
* in baseline neutral on top of a flavoured page. `inverseSurface` is the
|
||||||
|
* snackbar — the app's only error channel — and `scrim` is the dim behind every
|
||||||
|
* dialog.
|
||||||
|
*
|
||||||
|
* Material names more roles than a palette has colours, so several are answered
|
||||||
|
* by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the
|
||||||
|
* blend behind them is the shared one. `SchemeRolesTest` asserts that nothing
|
||||||
|
* here is left at its baseline.
|
||||||
|
*/
|
||||||
|
internal fun schemeOf(palette: PaletteRecord): ColorScheme {
|
||||||
|
val base = if (palette.light) lightColorScheme() else darkColorScheme()
|
||||||
|
return base.copy(
|
||||||
|
primary = color(palette.accent),
|
||||||
|
onPrimary = color(palette.onAccent),
|
||||||
|
primaryContainer = color(palette.accentDim),
|
||||||
|
onPrimaryContainer = color(palette.text),
|
||||||
|
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
|
||||||
|
// is the accent taken towards the page, so it inverts with the rest.
|
||||||
|
inversePrimary = color(palette.accentDim),
|
||||||
|
secondary = color(palette.accentAlt),
|
||||||
|
onSecondary = color(palette.onAccent),
|
||||||
|
// The accent taken back towards the page, not the card's hover shade.
|
||||||
|
// `secondaryContainer` is what fills every tonal button and the
|
||||||
|
// navigation bar's selected pill, and `cardHover` sits one step off
|
||||||
|
// `card` — which on a dark flavour made all of them dark grey shapes on
|
||||||
|
// a dark grey row, readable as text but not as controls.
|
||||||
|
secondaryContainer = color(palette.accentDim),
|
||||||
|
onSecondaryContainer = color(palette.text),
|
||||||
|
// The palette has two hues, not three. Tertiary takes the second one —
|
||||||
|
// the gradient partner — rather than inventing a third that no flavour
|
||||||
|
// chose.
|
||||||
|
tertiary = color(palette.accentAlt),
|
||||||
|
onTertiary = color(palette.onAccent),
|
||||||
|
tertiaryContainer = color(palette.accentDim),
|
||||||
|
onTertiaryContainer = color(palette.text),
|
||||||
|
background = color(palette.background),
|
||||||
|
onBackground = color(palette.text),
|
||||||
|
surface = color(palette.surface),
|
||||||
|
onSurface = color(palette.text),
|
||||||
|
surfaceVariant = color(palette.card),
|
||||||
|
onSurfaceVariant = color(palette.muted),
|
||||||
|
// No elevation tint. Material tints a raised surface with `primary`,
|
||||||
|
// and the desktop draws no such tint at all — its rule is that the
|
||||||
|
// accent is the only strong colour in the window and everything wearing
|
||||||
|
// it is something to click. A card that is faintly accent-coloured
|
||||||
|
// because it happens to be raised breaks that.
|
||||||
|
surfaceTint = Color.Transparent,
|
||||||
|
// Genuinely inverted, both ways round: on a dark flavour this is a pale
|
||||||
|
// snackbar with dark ink, on Latte a dark one with pale ink.
|
||||||
|
inverseSurface = color(palette.text),
|
||||||
|
inverseOnSurface = color(palette.background),
|
||||||
|
error = color(palette.danger),
|
||||||
|
onError = color(palette.background),
|
||||||
|
errorContainer = color(palette.dangerDim),
|
||||||
|
onErrorContainer = color(palette.text),
|
||||||
|
// Swapped from what was here before, to match what Material draws with
|
||||||
|
// each: `outline` is a border meant to be seen (an outlined button,
|
||||||
|
// a swatch that is not selected), `outlineVariant` is a divider meant
|
||||||
|
// to be barely there. `border` is the fainter of the two.
|
||||||
|
outline = color(palette.muted),
|
||||||
|
outlineVariant = color(palette.border),
|
||||||
|
// Black in both of Material's own schemes, and for the same reason: a
|
||||||
|
// scrim is not a colour, it is an absence of light.
|
||||||
|
scrim = Color.Black,
|
||||||
|
surfaceBright = color(palette.elevated),
|
||||||
|
surfaceDim = color(palette.background),
|
||||||
|
// The containment ladder, in the order the palette already runs: the
|
||||||
|
// deepest colour in the flavour at the bottom, `elevated` — one step
|
||||||
|
// past the card's hover shade — at the top.
|
||||||
|
surfaceContainerLowest = color(palette.sunken),
|
||||||
|
surfaceContainerLow = color(palette.surface),
|
||||||
|
surfaceContainer = color(palette.card),
|
||||||
|
surfaceContainerHigh = color(palette.cardHover),
|
||||||
|
surfaceContainerHighest = color(palette.elevated),
|
||||||
|
// The fixed roles are Material's "the same in light and dark", which a
|
||||||
|
// per-flavour palette has no equivalent of. They take the accent pair
|
||||||
|
// rather than a baseline hue, so a component that reaches for one still
|
||||||
|
// lands inside the flavour.
|
||||||
|
primaryFixed = color(palette.accentDim),
|
||||||
|
primaryFixedDim = color(palette.accent),
|
||||||
|
onPrimaryFixed = color(palette.text),
|
||||||
|
onPrimaryFixedVariant = color(palette.muted),
|
||||||
|
secondaryFixed = color(palette.accentDim),
|
||||||
|
secondaryFixedDim = color(palette.accentAlt),
|
||||||
|
onSecondaryFixed = color(palette.text),
|
||||||
|
onSecondaryFixedVariant = color(palette.muted),
|
||||||
|
tertiaryFixed = color(palette.accentDim),
|
||||||
|
tertiaryFixedDim = color(palette.accentAlt),
|
||||||
|
onTertiaryFixed = color(palette.text),
|
||||||
|
onTertiaryFixedVariant = color(palette.muted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
|
||||||
|
private fun color(argb: UInt) = Color(argb.toInt())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shipped default, spelled out for the case where Rust cannot be asked.
|
||||||
|
*
|
||||||
|
* A `PaletteRecord` rather than a `ColorScheme`, so that it goes through
|
||||||
|
* [schemeOf] like any other palette and inherits every mapping above — a
|
||||||
|
* fallback that answered only ten roles would be the same bug in a smaller
|
||||||
|
* place. The values are `pstr_core::appearance::Palette::PROTON`.
|
||||||
|
*/
|
||||||
|
internal val ProtonFallback = PaletteRecord(
|
||||||
|
background = 0xFF0E0E12u,
|
||||||
|
surface = 0xFF17171Du,
|
||||||
|
sunken = 0xFF0A0A0Du,
|
||||||
|
card = 0xFF1E1E26u,
|
||||||
|
cardHover = 0xFF2A2A35u,
|
||||||
|
border = 0xFF262630u,
|
||||||
|
text = 0xFFEAEAF0u,
|
||||||
|
muted = 0xFF8E8E9Cu,
|
||||||
|
accent = 0xFF7D4DFFu,
|
||||||
|
accentAlt = 0xFF3F6FE0u,
|
||||||
|
accentDim = 0xFF5333ADu,
|
||||||
|
onAccent = 0xFFEAEAF0u,
|
||||||
|
danger = 0xFFE05561u,
|
||||||
|
elevated = 0xFF333340u,
|
||||||
|
dangerDim = 0xFF7F2F37u,
|
||||||
|
light = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type ramp, which is the desktop client's type ramp.
|
||||||
|
*
|
||||||
|
* `pstr_app::theme::Role` names nine sizes and the desktop asks for a role
|
||||||
|
* rather than a number at every call site. Material names fifteen styles, so
|
||||||
|
* several of them land on the same rung — that is the point. The two clients
|
||||||
|
* were built to share one palette; sharing one scale is the other half of why
|
||||||
|
* they should read as one app.
|
||||||
|
*
|
||||||
|
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||||
|
*
|
||||||
|
* Where a role maps is decided by what actually draws with it here, not by
|
||||||
|
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
||||||
|
* a phone's lock screen and not for anything in a library grid. `bodyLarge` is
|
||||||
|
* what an unstyled `Text` gets, so it takes the desktop's body rung and the two
|
||||||
|
* smaller body styles step down from there.
|
||||||
|
*
|
||||||
|
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||||
|
*/
|
||||||
|
internal val ProtonTypography = Typography(
|
||||||
|
displayLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||||
|
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||||
|
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||||
|
// The name of the title you opened — the desktop's display line.
|
||||||
|
headlineLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||||
|
headlineMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||||
|
// A dialog's heading — `AlertDialog` titles draw with this one.
|
||||||
|
headlineSmall = role(HEADING, FontWeight.SemiBold),
|
||||||
|
// A page heading: "Offline downloads", "Settings".
|
||||||
|
titleLarge = role(TITLE, FontWeight.SemiBold),
|
||||||
|
// A run of content under a heading: "Continue watching", "Playback".
|
||||||
|
titleMedium = role(SECTION, FontWeight.Medium),
|
||||||
|
// A row that groups the rows under it — a season header.
|
||||||
|
titleSmall = role(SUBHEAD, FontWeight.Medium),
|
||||||
|
bodyLarge = role(BODY, FontWeight.Normal),
|
||||||
|
bodyMedium = role(LABEL, FontWeight.Normal),
|
||||||
|
// Context rather than content: counts, sizes, the note under a field.
|
||||||
|
bodySmall = role(CAPTION, FontWeight.Normal),
|
||||||
|
// The text on a button.
|
||||||
|
labelLarge = role(LABEL, FontWeight.Medium),
|
||||||
|
labelMedium = role(CAPTION, FontWeight.Medium),
|
||||||
|
// The smallest thing that stays legible.
|
||||||
|
labelSmall = role(MICRO, FontWeight.Medium),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal const val DISPLAY = 26f
|
||||||
|
internal const val TITLE = 20f
|
||||||
|
internal const val HEADING = 18f
|
||||||
|
internal const val SECTION = 17f
|
||||||
|
internal const val SUBHEAD = 15f
|
||||||
|
internal const val BODY = 14f
|
||||||
|
internal const val LABEL = 13f
|
||||||
|
internal const val CAPTION = 12f
|
||||||
|
internal const val MICRO = 11f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rung as a `TextStyle`.
|
||||||
|
*
|
||||||
|
* Line height is derived rather than named per style: a ramp whose leading was
|
||||||
|
* chosen nine separate times is the same problem the sizes had. 1.35 is loose
|
||||||
|
* enough for the two-line title names in the grid and tight enough that a
|
||||||
|
* label on a control does not float.
|
||||||
|
*/
|
||||||
|
private fun role(size: Float, weight: FontWeight) = TextStyle(
|
||||||
|
fontSize = size.sp,
|
||||||
|
lineHeight = (size * 1.35f).sp,
|
||||||
|
fontWeight = weight,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!--
|
||||||
|
The status-bar glyph. Android tints small icons to a single colour and drops
|
||||||
|
everything that is not opaque, so this is the launcher mark reduced to one
|
||||||
|
white silhouette rather than the two-tone drawable.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
`pstr_core::appearance::Palette::PROTON.background` — the shipped default,
|
||||||
|
and the only colour in this file. It is what the window is painted before
|
||||||
|
the stored palette has been read; from then on
|
||||||
|
MainActivity.dressWindow overrides it with the flavour in force.
|
||||||
|
-->
|
||||||
|
<color name="window_background">#0E0E12</color>
|
||||||
|
</resources>
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
<!--
|
||||||
|
The window before Compose owns it.
|
||||||
|
|
||||||
|
Everything about the *look* is decided by the stored palette and applied
|
||||||
|
in MainActivity.dressWindow: the background colour here is only the one
|
||||||
|
the shipped default resolves to, so that a first-ever launch — before
|
||||||
|
there is a theme file to read — starts on the right colour rather than on
|
||||||
|
the platform's white. The bar polarity is likewise set from the palette;
|
||||||
|
what is left here is the dark reading, which is right for four of the five
|
||||||
|
flavours and is corrected in the same frame for the fifth.
|
||||||
|
|
||||||
|
A hex literal from one specific flavour used to live here, and it was the
|
||||||
|
reason every cold start flashed Catppuccin Mocha's base regardless of what
|
||||||
|
the viewer had chosen.
|
||||||
|
-->
|
||||||
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
|
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
|
||||||
<item name="android:fontFamily">sans</item>
|
<item name="android:fontFamily">sans</item>
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
<item name="android:windowLightNavigationBar">false</item>
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
<item name="android:windowBackground">#1E1E2E</item>
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class DownloadStateStoreTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var store: DownloadStateStore
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
store = DownloadStateStore(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun record(
|
||||||
|
shareId: String = "share-1",
|
||||||
|
linkId: String = "link-1",
|
||||||
|
label: String = "S01E01.mkv",
|
||||||
|
status: String = RetainedDownload.STATUS_RUNNING,
|
||||||
|
error: String? = null,
|
||||||
|
) = RetainedDownload(
|
||||||
|
shareId = shareId,
|
||||||
|
volumeId = "volume-1",
|
||||||
|
linkId = linkId,
|
||||||
|
label = label,
|
||||||
|
downloaded = 4L * 1024 * 1024,
|
||||||
|
total = 761L * 1024 * 1024,
|
||||||
|
status = status,
|
||||||
|
error = error,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a record survives a round trip unchanged`() {
|
||||||
|
val original = record()
|
||||||
|
store.put(original)
|
||||||
|
assertEquals(original, store.get("share-1", "link-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an absent record reads as null rather than a default`() {
|
||||||
|
assertNull(store.get("share-1", "link-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an error message round trips and a null one stays null`() {
|
||||||
|
store.put(record(error = "no space left on device"))
|
||||||
|
assertEquals("no space left on device", store.get("share-1", "link-1")?.error)
|
||||||
|
|
||||||
|
store.put(record(error = null))
|
||||||
|
assertNull(store.get("share-1", "link-1")?.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `encode` writes `error` through `JSONObject.put`, which *removes* the key
|
||||||
|
* when the value is null. `decode` then reads `optString`, which answers ""
|
||||||
|
* rather than null. The blank check is the only thing standing between a
|
||||||
|
* cleared error and a download that renders as permanently failed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a blank error is not read as a failure message`() {
|
||||||
|
writeRaw(
|
||||||
|
"share-1",
|
||||||
|
"link-1",
|
||||||
|
baseJson().put("error", ""),
|
||||||
|
)
|
||||||
|
assertNull(store.get("share-1", "link-1")?.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows written before `label` existed fall back to the link id. */
|
||||||
|
@Test
|
||||||
|
fun `a record with no label is captioned with its link id`() {
|
||||||
|
writeRaw("share-1", "link-1", baseJson().apply { remove("label") })
|
||||||
|
assertEquals("link-1", store.get("share-1", "link-1")?.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a corrupt row is skipped rather than failing the whole list`() {
|
||||||
|
store.put(record(linkId = "link-1", label = "good.mkv"))
|
||||||
|
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString("share-1\u001flink-broken", "{ not json")
|
||||||
|
.commit()
|
||||||
|
|
||||||
|
val records = store.records()
|
||||||
|
assertEquals(1, records.size)
|
||||||
|
assertEquals("good.mkv", records.first().label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `records are ordered by label without regard to case`() {
|
||||||
|
store.put(record(linkId = "a", label = "beta.mkv"))
|
||||||
|
store.put(record(linkId = "b", label = "Alpha.mkv"))
|
||||||
|
assertEquals(listOf("Alpha.mkv", "beta.mkv"), store.records().map { it.label })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing one share leaves the others alone`() {
|
||||||
|
store.put(record(shareId = "share-1", linkId = "a"))
|
||||||
|
store.put(record(shareId = "share-2", linkId = "b"))
|
||||||
|
|
||||||
|
store.removeShare("share-1")
|
||||||
|
|
||||||
|
assertEquals(listOf("share-2"), store.records().map { it.shareId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear forgets everything`() {
|
||||||
|
store.put(record(linkId = "a"))
|
||||||
|
store.put(record(linkId = "b"))
|
||||||
|
store.clear()
|
||||||
|
assertTrue(store.records().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key is what `DownloadCoordinator` and `OfflineDownloadWorker` agree on
|
||||||
|
* across processes; two shares may legitimately hold the same link id.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the key separates share from link so ids cannot run together`() {
|
||||||
|
store.put(record(shareId = "a", linkId = "b-c"))
|
||||||
|
store.put(record(shareId = "a-b", linkId = "c"))
|
||||||
|
assertEquals(2, store.records().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun baseJson() = JSONObject()
|
||||||
|
.put("share", "share-1")
|
||||||
|
.put("volume", "volume-1")
|
||||||
|
.put("link", "link-1")
|
||||||
|
.put("label", "S01E01.mkv")
|
||||||
|
.put("downloaded", 0)
|
||||||
|
.put("total", 0)
|
||||||
|
.put("status", RetainedDownload.STATUS_QUEUED)
|
||||||
|
|
||||||
|
private fun writeRaw(shareId: String, linkId: String, json: JSONObject) {
|
||||||
|
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString("$shareId\u001f$linkId", json.toString())
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFERENCES = "offline_download_state"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* That every Material colour role is answered by the palette.
|
||||||
|
*
|
||||||
|
* Material names roughly fifty roles and a `ColorScheme` is built by copying a
|
||||||
|
* baseline, so a role nobody names keeps Material's own purple-and-grey —
|
||||||
|
* silently, and only where that particular role happens to be drawn. That is
|
||||||
|
* how `surfaceContainerHighest` went unnoticed: nothing referred to it by name,
|
||||||
|
* but it is what `Card` fills with, so every tile, episode row, share and
|
||||||
|
* download in the app was baseline neutral on a flavoured page.
|
||||||
|
*
|
||||||
|
* The check is reflective rather than a list, because a list is exactly as
|
||||||
|
* incomplete as the mapping it was written from — and because Material adds
|
||||||
|
* roles between versions, and a role added tomorrow should fail this the day
|
||||||
|
* the dependency moves.
|
||||||
|
*/
|
||||||
|
class SchemeRolesTest {
|
||||||
|
/**
|
||||||
|
* A palette whose every colour is distinct and none of which Material would
|
||||||
|
* ever produce, so that "this role came from the palette" is decidable by
|
||||||
|
* looking at the value.
|
||||||
|
*/
|
||||||
|
private fun sentinels(light: Boolean) = PaletteRecord(
|
||||||
|
background = 0xFF010203u,
|
||||||
|
surface = 0xFF040506u,
|
||||||
|
sunken = 0xFF070809u,
|
||||||
|
card = 0xFF0A0B0Cu,
|
||||||
|
cardHover = 0xFF0D0E0Fu,
|
||||||
|
border = 0xFF101112u,
|
||||||
|
text = 0xFF131415u,
|
||||||
|
muted = 0xFF161718u,
|
||||||
|
accent = 0xFF191A1Bu,
|
||||||
|
accentAlt = 0xFF1C1D1Eu,
|
||||||
|
accentDim = 0xFF1F2021u,
|
||||||
|
onAccent = 0xFF222324u,
|
||||||
|
danger = 0xFF252627u,
|
||||||
|
elevated = 0xFF28292Au,
|
||||||
|
dangerDim = 0xFF2B2C2Du,
|
||||||
|
light = light,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Every role on a `ColorScheme`, read by name, whatever the version names. */
|
||||||
|
private fun roles(scheme: ColorScheme): Map<String, Color> =
|
||||||
|
ColorScheme::class.java.declaredMethods
|
||||||
|
.filter { it.name.startsWith("get") && it.parameterCount == 0 && it.returnType == Long::class.java }
|
||||||
|
.associate { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
// Kotlin mangles the getter of a value-class property:
|
||||||
|
// `getPrimary-0d7_KjU`. The property is what the name says up
|
||||||
|
// to the dash.
|
||||||
|
method.name.removePrefix("get").substringBefore('-') to
|
||||||
|
Color((method.invoke(scheme) as Long).toULong())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every role is answered by the palette`() {
|
||||||
|
for (light in listOf(false, true)) {
|
||||||
|
val palette = sentinels(light)
|
||||||
|
val allowed = buildSet {
|
||||||
|
addAll(
|
||||||
|
listOf(
|
||||||
|
palette.background, palette.surface, palette.sunken, palette.card,
|
||||||
|
palette.cardHover, palette.border, palette.text, palette.muted,
|
||||||
|
palette.accent, palette.accentAlt, palette.accentDim, palette.onAccent,
|
||||||
|
palette.danger, palette.elevated, palette.dangerDim,
|
||||||
|
).map { Color(it.toInt()) },
|
||||||
|
)
|
||||||
|
// The two roles that are deliberately not a palette colour: no
|
||||||
|
// elevation tint at all, and a scrim that is an absence of
|
||||||
|
// light rather than a hue.
|
||||||
|
add(Color.Transparent)
|
||||||
|
add(Color.Black)
|
||||||
|
}
|
||||||
|
|
||||||
|
val leaked = roles(schemeOf(palette)).filterValues { it !in allowed }
|
||||||
|
assertTrue(
|
||||||
|
"light=$light: ${leaked.keys.sorted()} still carry Material's baseline",
|
||||||
|
leaked.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The count is asserted too. A reflective sweep that found no roles at all
|
||||||
|
* would pass the check above for the wrong reason.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the sweep sees the whole scheme`() {
|
||||||
|
val count = roles(darkColorScheme()).size
|
||||||
|
assertTrue("only $count roles found; the reflective sweep is not working", count >= 40)
|
||||||
|
assertEquals(count, roles(lightColorScheme()).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which baseline was copied stops being observable once every role is
|
||||||
|
* mapped — and that is the point.
|
||||||
|
*
|
||||||
|
* `light` still decides plenty, but it decides it in Rust, where it picks
|
||||||
|
* the flavour's colours. By the time a palette reaches Kotlin the
|
||||||
|
* light-or-dark question is already answered *inside* the colours, so two
|
||||||
|
* palettes that differ only in the flag have to produce the same scheme. If
|
||||||
|
* they ever stop doing so, something is reading `light` here rather than
|
||||||
|
* reading the colours.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the baseline no longer shows through`() {
|
||||||
|
assertEquals(roles(schemeOf(sentinels(false))), roles(schemeOf(sentinels(true))))
|
||||||
|
assertEquals(Color.Transparent, schemeOf(sentinels(false)).surfaceTint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fallback goes through the same mapping as any other palette. */
|
||||||
|
@Test
|
||||||
|
fun `the shipped fallback is fully mapped too`() {
|
||||||
|
val scheme = schemeOf(ProtonFallback)
|
||||||
|
assertEquals(Color(ProtonFallback.elevated.toInt()), scheme.surfaceContainerHighest)
|
||||||
|
assertEquals(Color(ProtonFallback.card.toInt()), scheme.surfaceContainer)
|
||||||
|
assertEquals(Color(ProtonFallback.dangerDim.toInt()), scheme.errorContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* That the type ramp is the ramp, and the corner radius is the radius.
|
||||||
|
*
|
||||||
|
* The two clients share a palette because one Rust function resolves it for
|
||||||
|
* both. Type and shape have no such bridge — they are named in
|
||||||
|
* `pstr_app::theme::Role` and copied here — so what stops them drifting is this
|
||||||
|
* test and the numbers in it. Change the Rust ramp and this fails; that is the
|
||||||
|
* point of it.
|
||||||
|
*
|
||||||
|
* Every style is named rather than checked against a set, because "some rung"
|
||||||
|
* is not the property that matters: `bodyMedium`'s Material default is 14sp,
|
||||||
|
* which is a rung, so a `bodyMedium` nobody mapped would pass a looser check
|
||||||
|
* while sitting one step off where it belongs.
|
||||||
|
*/
|
||||||
|
class TypeRampTest {
|
||||||
|
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||||
|
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||||
|
|
||||||
|
private val expected = mapOf(
|
||||||
|
"displayLarge" to 26f,
|
||||||
|
"displayMedium" to 26f,
|
||||||
|
"displaySmall" to 20f,
|
||||||
|
"headlineLarge" to 26f,
|
||||||
|
"headlineMedium" to 26f,
|
||||||
|
"headlineSmall" to 18f,
|
||||||
|
"titleLarge" to 20f,
|
||||||
|
"titleMedium" to 17f,
|
||||||
|
"titleSmall" to 15f,
|
||||||
|
"bodyLarge" to 14f,
|
||||||
|
"bodyMedium" to 13f,
|
||||||
|
"bodySmall" to 12f,
|
||||||
|
"labelLarge" to 13f,
|
||||||
|
"labelMedium" to 12f,
|
||||||
|
"labelSmall" to 11f,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every text style sits on the shared ramp`() {
|
||||||
|
expected.forEach { (name, size) ->
|
||||||
|
assertEquals(name, size, styles().getValue(name).fontSize.value, 0.001f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material adds type styles between versions, and one added tomorrow would
|
||||||
|
* arrive at whatever size Material chose for it. The list above is only as
|
||||||
|
* complete as the day it was written unless something says so.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `no text style is left at Material's own size`() {
|
||||||
|
assertEquals(expected.keys, styles().keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading is derived from the size, so the only thing worth asserting is
|
||||||
|
* that it *was* derived — a style that kept Material's line height carries
|
||||||
|
* a number with no relation to its own size.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `line height follows the size it belongs to`() {
|
||||||
|
styles().forEach { (name, style) ->
|
||||||
|
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the ramp has no rung the styles never reach`() {
|
||||||
|
val drawn = styles().values.map { it.fontSize.value }.toSet()
|
||||||
|
ramp.forEach { rung -> assertTrue("$rung is named but never drawn", rung in drawn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every surface is drawn at the desktop's radius`() {
|
||||||
|
listOf(ProtonShapes.small, ProtonShapes.medium, ProtonShapes.large).forEach { shape ->
|
||||||
|
// `CornerSize` resolves against a size and a density, and neither is
|
||||||
|
// available to a unit test; `toString` is the only handle on the
|
||||||
|
// value that does not need a composition.
|
||||||
|
assertTrue("$shape is not radius $CORNER", shape.toString().contains("$CORNER"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every text style a caller can set, read by name, whatever the version
|
||||||
|
* names.
|
||||||
|
*
|
||||||
|
* Material 1.4 carries a second, `Emphasized` copy of all fifteen for its
|
||||||
|
* expressive components. Those are `internal` — the constructor takes them
|
||||||
|
* but Kotlin will not let this module pass them — so they keep Material's
|
||||||
|
* own sizes and nothing here can do anything about it. Their getters are
|
||||||
|
* name-mangled with `$material3`, which is how they are told apart from a
|
||||||
|
* public style that genuinely went unmapped.
|
||||||
|
*/
|
||||||
|
private fun styles(): Map<String, TextStyle> = Typography::class.java.declaredMethods
|
||||||
|
.filter { it.parameterCount == 0 && it.returnType == TextStyle::class.java }
|
||||||
|
.filterNot { '$' in it.name }
|
||||||
|
.associate { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) to
|
||||||
|
method.invoke(ProtonTypography) as TextStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Robolectric ships no Android image for API 36 yet, and defaults to the app's
|
||||||
|
# targetSdk, so host tests would fail to start. 34 is the newest it has and is
|
||||||
|
# well above minSdk 31; anything whose behaviour actually turns on the API level
|
||||||
|
# belongs in androidTest, on a real device, not here.
|
||||||
|
sdk=34
|
||||||
|
|
||||||
|
# ProtonStreamApplication.onCreate() calls System.loadLibrary("pstr_android"),
|
||||||
|
# which has no host build. Host tests get a plain Application; everything that
|
||||||
|
# needs the bridge is an instrumentation test by construction — see
|
||||||
|
# androidTest/.../UniFfiBoundaryTest.
|
||||||
|
application=android.app.Application
|
||||||
@@ -6,10 +6,14 @@ activity = "1.11.0"
|
|||||||
lifecycle = "2.9.4"
|
lifecycle = "2.9.4"
|
||||||
navigation = "2.9.5"
|
navigation = "2.9.5"
|
||||||
work = "2.10.5"
|
work = "2.10.5"
|
||||||
media3 = "1.8.0"
|
|
||||||
adaptive = "1.1.0"
|
adaptive = "1.1.0"
|
||||||
jna = "5.17.0"
|
jna = "5.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
robolectric = "4.16"
|
||||||
|
androidx-test-core = "1.7.0"
|
||||||
|
androidx-test-junit = "1.3.0"
|
||||||
|
androidx-test-runner = "1.7.0"
|
||||||
|
ktlint-plugin = "13.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||||
@@ -18,8 +22,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
|
|||||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||||
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
|
||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
@@ -29,11 +32,18 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
|||||||
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
||||||
androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" }
|
androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" }
|
||||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||||
|
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||||
|
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
||||||
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||||
@@ -29,6 +29,7 @@ thiserror.workspace = true
|
|||||||
uniffi.workspace = true
|
uniffi.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
# Match rustls-platform-verifier's JNI types: Java objects from different
|
# Match rustls-platform-verifier's JNI types: Java objects from different
|
||||||
@@ -36,4 +37,3 @@ serde_json.workspace = true
|
|||||||
jni = "0.21.1"
|
jni = "0.21.1"
|
||||||
rustls-platform-verifier = "0.6.2"
|
rustls-platform-verifier = "0.6.2"
|
||||||
android_logger = "0.14"
|
android_logger = "0.14"
|
||||||
log = "0.4"
|
|
||||||
+1486
-157
File diff suppressed because it is too large.
Load diff
@@ -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,8 +24,25 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
|
egui-phosphor.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
directories.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
souvlaki.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
skrifa.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
dbus.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
# Named pipes, for handing a second launch's request to the open window.
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
|
||||||
|
tauri-winrt-notification.workspace = true
|
||||||
|
raw-window-handle = "0.6"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -92,7 +92,12 @@ impl Harness {
|
|||||||
.context("eframe was built without a glow context")?;
|
.context("eframe was built without a glow context")?;
|
||||||
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
||||||
// context current, which is the same guarantee `App::ui` gives.
|
// context current, which is the same guarantee `App::ui` gives.
|
||||||
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
|
let video = unsafe {
|
||||||
|
VideoSurface::new(Arc::clone(&player), gl, {
|
||||||
|
let ctx = cc.egui_ctx.clone();
|
||||||
|
move || ctx.request_repaint()
|
||||||
|
})
|
||||||
|
}?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
player,
|
player,
|
||||||
|
|||||||
+1224
-138
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
|||||||
|
//! Settings only the desktop app has.
|
||||||
|
//!
|
||||||
|
//! Kept out of [`pstr_core::prefs::PlaybackPrefs`] on purpose: that one is
|
||||||
|
//! shared with Android through a bridge record, and a field Android neither
|
||||||
|
//! shows nor keeps would be reset to its default every time Android saved.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the settings page offers for the streaming cache, in GiB.
|
||||||
|
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
// A file from an older version, or edited by hand, missing a field: the rest
|
||||||
|
// still counts, and the missing one takes its default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DesktopPrefs {
|
||||||
|
/// Let mpv decode on the GPU. Off is for a driver that shows green frames
|
||||||
|
/// or nothing; it costs CPU, not correctness.
|
||||||
|
pub hardware_decoding: bool,
|
||||||
|
/// How much decrypted video the disk cache may keep.
|
||||||
|
pub cache_gib: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DesktopPrefs {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hardware_decoding: true,
|
||||||
|
cache_gib: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopPrefs {
|
||||||
|
pub fn cache_bytes(&self) -> u64 {
|
||||||
|
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefs and where they live.
|
||||||
|
pub struct DesktopPrefsFile {
|
||||||
|
path: PathBuf,
|
||||||
|
/// False when the file was there and would not parse. Such a file is never
|
||||||
|
/// overwritten — it is far more likely a bug than something to replace —
|
||||||
|
/// so changes then last only for the session.
|
||||||
|
writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopPrefsFile {
|
||||||
|
pub fn load(path: PathBuf) -> (Self, DesktopPrefs) {
|
||||||
|
match pstr_core::config::read_json::<DesktopPrefs>(&path) {
|
||||||
|
Ok(prefs) => (
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
writable: true,
|
||||||
|
},
|
||||||
|
prefs.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("read the desktop settings: {error}");
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
DesktopPrefs::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, prefs: &DesktopPrefs) -> pstr_core::Result<()> {
|
||||||
|
if !self.writable {
|
||||||
|
tracing::warn!(
|
||||||
|
"not saving the desktop settings over {}, which would not parse",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
pstr_core::config::write_json(&self.path, prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_missing_a_field_keeps_the_rest() {
|
||||||
|
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
|
||||||
|
assert_eq!(prefs.cache_gib, 8);
|
||||||
|
assert!(prefs.hardware_decoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_file_is_never_overwritten() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||||
|
std::fs::write(&path, "{ not json").unwrap();
|
||||||
|
let (file, prefs) = DesktopPrefsFile::load(path.clone());
|
||||||
|
assert_eq!(prefs, DesktopPrefs::default());
|
||||||
|
file.save(&DesktopPrefs::default()).unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
+820
-277
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,185 @@
|
|||||||
|
//! Keeping the screen on while a film plays.
|
||||||
|
//!
|
||||||
|
//! mpv does this itself for a window of its own (`stop-screensaver`), but here
|
||||||
|
//! the picture is drawn into the app's window through the render API, and mpv
|
||||||
|
//! has no window to hold the screensaver off for. So the app asks.
|
||||||
|
//!
|
||||||
|
//! Best effort, like the media controls: a desktop that does not answer means
|
||||||
|
//! the screen may blank during a film, which is what happened before this.
|
||||||
|
|
||||||
|
/// Holds the screensaver off while [`Inhibitor::set`] says so.
|
||||||
|
pub struct Inhibitor {
|
||||||
|
held: bool,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
orders: Option<std::sync::mpsc::Sender<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inhibitor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
held: false,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
orders: linux::spawn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold or release. Called every frame; acts only on a change.
|
||||||
|
pub fn set(&mut self, hold: bool) {
|
||||||
|
if hold == self.held {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.held = hold;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(orders) = &self.orders {
|
||||||
|
let _ = orders.send(hold);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
windows::set(hold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Inhibitor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Inhibitor {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux {
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use dbus::Path;
|
||||||
|
use dbus::arg::PropMap;
|
||||||
|
use dbus::blocking::Connection;
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
const REASON: &str = "Playing a video";
|
||||||
|
|
||||||
|
/// What is holding the screensaver off, and how to let go of it.
|
||||||
|
enum Hold {
|
||||||
|
/// The desktop portal: released by closing the request it returned.
|
||||||
|
Portal(Path<'static>),
|
||||||
|
/// The older `org.freedesktop.ScreenSaver`: released by its cookie.
|
||||||
|
ScreenSaver(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thread of its own, holding the session bus connection. The hold is
|
||||||
|
/// tied to that connection — closing it releases the hold — so it has to
|
||||||
|
/// outlive any one call, and a D-Bus call that stalls must not stall a
|
||||||
|
/// frame.
|
||||||
|
pub fn spawn() -> Option<Sender<bool>> {
|
||||||
|
let (orders, receiver) = channel::<bool>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("inhibit".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let connection = match Connection::new_session() {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!("no session bus to inhibit the screensaver on: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut hold = None;
|
||||||
|
for wanted in receiver {
|
||||||
|
match (wanted, hold.take()) {
|
||||||
|
(true, None) => hold = acquire(&connection),
|
||||||
|
(false, Some(held)) => release(&connection, held),
|
||||||
|
(_, held) => hold = held,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(held) = hold {
|
||||||
|
release(&connection, held);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("inhibit thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire(connection: &Connection) -> Option<Hold> {
|
||||||
|
// The portal first: it is what works under Flatpak and on GNOME, which
|
||||||
|
// does not implement the screensaver interface's `Inhibit`.
|
||||||
|
let portal = connection.with_proxy(
|
||||||
|
"org.freedesktop.portal.Desktop",
|
||||||
|
"/org/freedesktop/portal/desktop",
|
||||||
|
TIMEOUT,
|
||||||
|
);
|
||||||
|
let mut options = PropMap::new();
|
||||||
|
options.insert(
|
||||||
|
"reason".into(),
|
||||||
|
dbus::arg::Variant(Box::new(REASON.to_owned())),
|
||||||
|
);
|
||||||
|
// 8 is "idle": the screen blanking or locking for want of input.
|
||||||
|
let answer: Result<(Path<'static>,), _> = portal.method_call(
|
||||||
|
"org.freedesktop.portal.Inhibit",
|
||||||
|
"Inhibit",
|
||||||
|
("", 8u32, options),
|
||||||
|
);
|
||||||
|
match answer {
|
||||||
|
Ok((request,)) => return Some(Hold::Portal(request)),
|
||||||
|
Err(error) => tracing::debug!("portal inhibit: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let screensaver = connection.with_proxy(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"/org/freedesktop/ScreenSaver",
|
||||||
|
TIMEOUT,
|
||||||
|
);
|
||||||
|
let answer: Result<(u32,), _> = screensaver.method_call(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"Inhibit",
|
||||||
|
("proton-stream", REASON),
|
||||||
|
);
|
||||||
|
match answer {
|
||||||
|
Ok((cookie,)) => Some(Hold::ScreenSaver(cookie)),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!("screensaver inhibit: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(connection: &Connection, hold: Hold) {
|
||||||
|
let result: Result<(), dbus::Error> = match hold {
|
||||||
|
Hold::Portal(request) => connection
|
||||||
|
.with_proxy("org.freedesktop.portal.Desktop", request, TIMEOUT)
|
||||||
|
.method_call("org.freedesktop.portal.Request", "Close", ()),
|
||||||
|
Hold::ScreenSaver(cookie) => connection
|
||||||
|
.with_proxy(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"/org/freedesktop/ScreenSaver",
|
||||||
|
TIMEOUT,
|
||||||
|
)
|
||||||
|
.method_call("org.freedesktop.ScreenSaver", "UnInhibit", (cookie,)),
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
tracing::debug!("release the screensaver: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows {
|
||||||
|
use windows_sys::Win32::System::Power::{
|
||||||
|
ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, SetThreadExecutionState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn set(hold: bool) {
|
||||||
|
let flags = if hold {
|
||||||
|
ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED
|
||||||
|
} else {
|
||||||
|
ES_CONTINUOUS
|
||||||
|
};
|
||||||
|
// SAFETY: takes flags by value and touches nothing of ours.
|
||||||
|
unsafe {
|
||||||
|
SetThreadExecutionState(flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,15 @@
|
|||||||
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
|
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod desktop_prefs;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod inhibit;
|
||||||
|
pub mod instance;
|
||||||
|
pub mod media;
|
||||||
|
pub mod notify;
|
||||||
|
pub mod pacing;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
pub mod window;
|
||||||
@@ -31,20 +31,44 @@ fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||||
|
|
||||||
let options = eframe::NativeOptions {
|
// Before the window: a second launch hands its request to the first and
|
||||||
viewport: egui::ViewportBuilder::default()
|
// never opens one.
|
||||||
|
let request = pstr_app::instance::Request::from_args(std::env::args().skip(1));
|
||||||
|
let instance = match pstr_app::instance::claim(&runtime, &dirs, &request) {
|
||||||
|
pstr_app::instance::Claim::First(listener) => listener,
|
||||||
|
pstr_app::instance::Claim::Handed => {
|
||||||
|
tracing::info!("handed over to the window already open");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let window = pstr_app::window::WindowState::load(&dirs.window_file());
|
||||||
|
let mut viewport = egui::ViewportBuilder::default();
|
||||||
|
// X11 and Windows take the icon from the window. Wayland ignores this and
|
||||||
|
// looks the app id up in the installed desktop entry instead.
|
||||||
|
match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) {
|
||||||
|
Ok(icon) => viewport = viewport.with_icon(icon),
|
||||||
|
Err(error) => tracing::debug!("window icon: {error}"),
|
||||||
|
}
|
||||||
|
let mut options = eframe::NativeOptions {
|
||||||
|
viewport: viewport
|
||||||
.with_title("proton-stream")
|
.with_title("proton-stream")
|
||||||
.with_inner_size([1180.0, 780.0])
|
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
|
||||||
|
.with_maximized(window.is_some_and(|state| state.maximized))
|
||||||
.with_min_inner_size([720.0, 480.0])
|
.with_min_inner_size([720.0, 480.0])
|
||||||
.with_app_id("io.narl.proton-stream"),
|
.with_app_id("io.narl.proton-stream"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
// Off on Wayland, where a swap on a window nobody is looking at never
|
||||||
|
// returns and the compositor decides the app has hung. `pstr_app::pacing`
|
||||||
|
// has the whole of it, and caps the frame rate in vsync's place.
|
||||||
|
options.glow_options.vsync = pstr_app::pacing::vsync();
|
||||||
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"proton-stream",
|
"proton-stream",
|
||||||
options,
|
options,
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
pstr_app::app::App::new(cc, runtime, dirs)
|
pstr_app::app::App::new(cc, runtime, dirs, instance, request)
|
||||||
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
||||||
.map_err(|error| error.into())
|
.map_err(|error| error.into())
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! The desktop's view of what is playing: media keys, the media widget in the
|
||||||
|
//! panel, the lock screen.
|
||||||
|
//!
|
||||||
|
//! MPRIS on Linux, the System Media Transport Controls on Windows, through
|
||||||
|
//! `souvlaki`. Both are optional in the strongest sense — a session with no
|
||||||
|
//! D-Bus, or a desktop with nothing listening, plays exactly as before — so
|
||||||
|
//! every failure here is a debug line and nothing more.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use souvlaki::{
|
||||||
|
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
|
||||||
|
SeekDirection,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{Action, Adjacent};
|
||||||
|
use crate::playback::{Command, Playback};
|
||||||
|
|
||||||
|
/// How far one press of a seek key moves, when the desktop does not say.
|
||||||
|
const SEEK_SECONDS: f64 = 10.0;
|
||||||
|
|
||||||
|
/// What was last told to the desktop, so it is told again only on a change.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct Told {
|
||||||
|
/// What the metadata was built from: the player, whether its duration was
|
||||||
|
/// known yet, and the cover. Any of them changing means sending it again.
|
||||||
|
item: Option<(u64, bool, Option<String>)>,
|
||||||
|
paused: bool,
|
||||||
|
/// Where the playhead was, to the second. A desktop interpolates between
|
||||||
|
/// updates, so this only has to be sent when the playhead jumps.
|
||||||
|
second: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MediaSession {
|
||||||
|
controls: Option<MediaControls>,
|
||||||
|
events: Receiver<MediaControlEvent>,
|
||||||
|
told: Option<Told>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSession {
|
||||||
|
/// Register with the desktop. `window` is the handle SMTC needs on
|
||||||
|
/// Windows; elsewhere it is not used.
|
||||||
|
pub fn new(ctx: &egui::Context, window: Option<*mut std::ffi::c_void>) -> Self {
|
||||||
|
let (sender, events) = channel();
|
||||||
|
let config = PlatformConfig {
|
||||||
|
display_name: "proton-stream",
|
||||||
|
dbus_name: "proton_stream",
|
||||||
|
hwnd: window,
|
||||||
|
};
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let controls = MediaControls::new(config)
|
||||||
|
.and_then(|mut controls| {
|
||||||
|
controls.attach(move |event| {
|
||||||
|
// The press arrives on the D-Bus thread; it is acted on in
|
||||||
|
// the next frame, which this is what asks for.
|
||||||
|
let _ = sender.send(event);
|
||||||
|
ctx.request_repaint();
|
||||||
|
})?;
|
||||||
|
Ok(controls)
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("media controls unavailable: {error:?}"))
|
||||||
|
.ok();
|
||||||
|
Self {
|
||||||
|
controls,
|
||||||
|
events,
|
||||||
|
told: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the presses since the last frame into actions.
|
||||||
|
pub fn drain(&mut self, playback: Option<&Playback>, actions: &mut Vec<Action>) {
|
||||||
|
while let Ok(event) = self.events.try_recv() {
|
||||||
|
if let Some(action) = action_for(event, playback) {
|
||||||
|
actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the desktop what is playing, if that changed since the last frame.
|
||||||
|
///
|
||||||
|
/// `cover` is a `file://` URL to the title's artwork when it is on disk.
|
||||||
|
pub fn show(&mut self, playback: Option<&Playback>, cover: Option<&str>) {
|
||||||
|
let Some(controls) = self.controls.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let told = Told {
|
||||||
|
item: playback.map(|playback| {
|
||||||
|
(
|
||||||
|
playback.id,
|
||||||
|
playback.duration.is_some(),
|
||||||
|
cover.map(str::to_owned),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
paused: playback.is_none_or(|playback| playback.paused),
|
||||||
|
second: playback.map_or(0, |playback| playback.position as i64),
|
||||||
|
};
|
||||||
|
let previous = self.told.replace(told.clone());
|
||||||
|
// Within the second, or one second on from it: playback carrying on,
|
||||||
|
// which the desktop already shows without being told.
|
||||||
|
if let Some(previous) = &previous
|
||||||
|
&& previous.item == told.item
|
||||||
|
&& previous.paused == told.paused
|
||||||
|
&& (0..=1).contains(&(told.second - previous.second))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(playback) = playback else {
|
||||||
|
if let Err(error) = controls.set_playback(MediaPlayback::Stopped) {
|
||||||
|
tracing::debug!("media controls: {error:?}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if previous.is_none_or(|previous| previous.item != told.item) {
|
||||||
|
let target = &playback.target;
|
||||||
|
let episode = target.caption();
|
||||||
|
let metadata = MediaMetadata {
|
||||||
|
title: Some(if episode.is_empty() {
|
||||||
|
&target.title_name
|
||||||
|
} else {
|
||||||
|
&episode
|
||||||
|
}),
|
||||||
|
album: Some(&target.title_name),
|
||||||
|
artist: None,
|
||||||
|
cover_url: cover,
|
||||||
|
duration: playback.duration.map(Duration::from_secs_f64),
|
||||||
|
};
|
||||||
|
if let Err(error) = controls.set_metadata(metadata) {
|
||||||
|
tracing::debug!("media controls: {error:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let progress = Some(MediaPosition(Duration::from_secs_f64(
|
||||||
|
playback.position.max(0.0),
|
||||||
|
)));
|
||||||
|
let state = if playback.paused {
|
||||||
|
MediaPlayback::Paused { progress }
|
||||||
|
} else {
|
||||||
|
MediaPlayback::Playing { progress }
|
||||||
|
};
|
||||||
|
if let Err(error) = controls.set_playback(state) {
|
||||||
|
tracing::debug!("media controls: {error:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a press means here. `None` for the ones with nothing to act on.
|
||||||
|
fn action_for(event: MediaControlEvent, playback: Option<&Playback>) -> Option<Action> {
|
||||||
|
let playback = playback?;
|
||||||
|
let seek = |direction: SeekDirection, seconds: f64| {
|
||||||
|
Action::Player(Command::SeekBy(match direction {
|
||||||
|
SeekDirection::Forward => seconds,
|
||||||
|
SeekDirection::Backward => -seconds,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
Some(match event {
|
||||||
|
MediaControlEvent::Toggle => Action::Player(Command::TogglePause),
|
||||||
|
MediaControlEvent::Play if playback.paused => Action::Player(Command::TogglePause),
|
||||||
|
MediaControlEvent::Pause if !playback.paused => Action::Player(Command::TogglePause),
|
||||||
|
MediaControlEvent::Next => Action::PlayAdjacent(Adjacent::Next),
|
||||||
|
MediaControlEvent::Previous => Action::PlayAdjacent(Adjacent::Previous),
|
||||||
|
MediaControlEvent::Stop => Action::LeavePlayer,
|
||||||
|
MediaControlEvent::Seek(direction) => seek(direction, SEEK_SECONDS),
|
||||||
|
MediaControlEvent::SeekBy(direction, by) => seek(direction, by.as_secs_f64()),
|
||||||
|
MediaControlEvent::SetPosition(MediaPosition(at)) => {
|
||||||
|
Action::Player(Command::SeekTo(at.as_secs_f64()))
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Desktop notifications, for things that finish while nobody is looking.
|
||||||
|
//!
|
||||||
|
//! Only when the window does not have focus: a viewer who is looking at the
|
||||||
|
//! app already has the toast, and a second copy of it from the panel is noise.
|
||||||
|
//! That rule is the caller's — see `App::notify`.
|
||||||
|
//!
|
||||||
|
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
|
||||||
|
//! macOS yet.
|
||||||
|
//!
|
||||||
|
//! A Windows toast is shown under an application id, and Windows drops a toast
|
||||||
|
//! whose id it does not know without a word. The id is registered for the
|
||||||
|
//! current user under `HKCU\Software\Classes\AppUserModelId` — the same
|
||||||
|
//! registration the Windows App SDK makes for an app with no package — which
|
||||||
|
//! works for the MSI and the portable zip alike. If that write fails, the
|
||||||
|
//! toast goes out under PowerShell's id instead, which Windows always knows: a
|
||||||
|
//! notification that names the wrong program beats one that never appears.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Sends notifications from a thread of its own, so a notification daemon
|
||||||
|
/// that is slow to answer never costs a frame.
|
||||||
|
pub struct Notifier {
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notifier {
|
||||||
|
/// `cache` is where Windows is pointed for the toast's icon.
|
||||||
|
pub fn new(cache: &Path) -> Self {
|
||||||
|
let _ = cache;
|
||||||
|
Self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
orders: linux::spawn(),
|
||||||
|
#[cfg(windows)]
|
||||||
|
orders: windows::spawn(cache.join("notification-icon.png")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
if let Some(orders) = &self.orders {
|
||||||
|
let _ = orders.send((summary.into(), body.into()));
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
let _ = (summary.into(), body.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux {
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use dbus::arg::PropMap;
|
||||||
|
use dbus::blocking::Connection;
|
||||||
|
|
||||||
|
pub fn spawn() -> Option<Sender<(String, String)>> {
|
||||||
|
let (orders, receiver) = channel::<(String, String)>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("notify".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Connected on first use rather than at startup: most sessions
|
||||||
|
// never send one.
|
||||||
|
let mut connection: Option<Connection> = None;
|
||||||
|
for (summary, body) in receiver {
|
||||||
|
if connection.is_none() {
|
||||||
|
connection = Connection::new_session()
|
||||||
|
.inspect_err(|error| tracing::debug!("notifications: {error}"))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let Some(connection) = &connection else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let proxy = connection.with_proxy(
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
"/org/freedesktop/Notifications",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
let answer: Result<(u32,), _> = proxy.method_call(
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
"Notify",
|
||||||
|
(
|
||||||
|
"proton-stream",
|
||||||
|
0u32,
|
||||||
|
"io.narl.proton-stream",
|
||||||
|
summary,
|
||||||
|
body,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
PropMap::new(),
|
||||||
|
-1i32,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if let Err(error) = answer {
|
||||||
|
tracing::debug!("notification: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
|
||||||
|
use tauri_winrt_notification::Toast;
|
||||||
|
|
||||||
|
/// The id toasts are shown under, and the key it is registered at.
|
||||||
|
const APP_ID: &str = "io.narl.proton-stream";
|
||||||
|
const ICON: &[u8] = include_bytes!("../assets/icon-256.png");
|
||||||
|
|
||||||
|
pub fn spawn(icon: PathBuf) -> Option<Sender<(String, String)>> {
|
||||||
|
let (orders, receiver) = channel::<(String, String)>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("notify".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Registered on first use rather than at startup: most
|
||||||
|
// sessions never send one.
|
||||||
|
let mut app_id: Option<&str> = None;
|
||||||
|
for (summary, body) in receiver {
|
||||||
|
let id = *app_id.get_or_insert_with(|| {
|
||||||
|
let _ = std::fs::write(&icon, ICON);
|
||||||
|
if register(&icon) {
|
||||||
|
APP_ID
|
||||||
|
} else {
|
||||||
|
Toast::POWERSHELL_APP_ID
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let shown = Toast::new(id).title(&summary).text1(&body).show();
|
||||||
|
if let Err(error) = shown {
|
||||||
|
tracing::debug!("notification: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell Windows the app id's display name and icon. Per user, no rights
|
||||||
|
/// needed, and written again every session so a moved install points at
|
||||||
|
/// a live icon.
|
||||||
|
fn register(icon: &Path) -> bool {
|
||||||
|
use windows_sys::Win32::System::Registry::{
|
||||||
|
HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
|
||||||
|
RegCreateKeyExW, RegSetValueExW,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn wide(text: &str) -> Vec<u16> {
|
||||||
|
text.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
|
||||||
|
let mut key: HKEY = std::ptr::null_mut();
|
||||||
|
// SAFETY: every pointer is to a live, NUL-terminated buffer or an out
|
||||||
|
// parameter on this stack frame, and the key is closed before return.
|
||||||
|
unsafe {
|
||||||
|
let created = RegCreateKeyExW(
|
||||||
|
HKEY_CURRENT_USER,
|
||||||
|
path.as_ptr(),
|
||||||
|
0,
|
||||||
|
std::ptr::null(),
|
||||||
|
REG_OPTION_NON_VOLATILE,
|
||||||
|
KEY_WRITE,
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut key,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if created != 0 {
|
||||||
|
tracing::debug!("register the notification id: error {created}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let icon = icon.to_string_lossy();
|
||||||
|
let mut written = true;
|
||||||
|
for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] {
|
||||||
|
let name = wide(name);
|
||||||
|
let value = wide(value);
|
||||||
|
let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX);
|
||||||
|
let set =
|
||||||
|
RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes);
|
||||||
|
written &= set == 0;
|
||||||
|
}
|
||||||
|
RegCloseKey(key);
|
||||||
|
written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! How often the window is allowed to draw — and why it does not simply let
|
||||||
|
//! vsync decide on Wayland.
|
||||||
|
//!
|
||||||
|
//! ## The bug this exists for
|
||||||
|
//!
|
||||||
|
//! A Wayland compositor throttles a client by *withholding* the `wl_surface`
|
||||||
|
//! frame callback, and it withholds it for as long as the surface is not being
|
||||||
|
//! shown — a window on another workspace, or behind a fullscreen one. Mesa's
|
||||||
|
//! EGL implements a swap interval of 1 by waiting on exactly that callback, so
|
||||||
|
//! `eglSwapBuffers` on a hidden surface does not return late, it does not
|
||||||
|
//! return at all:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! eframe run_ui_and_paint ─▶ glutin swap_buffers ─▶ eglSwapBuffers
|
||||||
|
//! │
|
||||||
|
//! wl_display_dispatch_queue ◀─┘ ppoll, forever
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! That is the whole of the UI thread, and the same thread is the one that
|
||||||
|
//! answers `xdg_wm_base.ping`. Hyprland pings a client it has not heard from
|
||||||
|
//! and puts up an "application is not responding" dialog when the pong does not
|
||||||
|
//! come, which is what a viewer sees a few seconds after switching away from a
|
||||||
|
//! playing window. Nothing is actually wrong — coming back releases the frame
|
||||||
|
//! callback and the app carries on — but a dialog offering to kill the player
|
||||||
|
//! is not an acceptable way to say "this window is not visible".
|
||||||
|
//!
|
||||||
|
//! winit cannot rescue this for us: `WindowEvent::Occluded` is X11, macOS and
|
||||||
|
//! web only — its Wayland implementation was deliberately removed in 0.29 — so
|
||||||
|
//! eframe never learns the window is hidden and keeps painting it.
|
||||||
|
//!
|
||||||
|
//! ## What is done instead
|
||||||
|
//!
|
||||||
|
//! On Wayland the swap interval is set to 0 ([`vsync`]), which makes
|
||||||
|
//! `eglSwapBuffers` return immediately whether the surface is being shown or
|
||||||
|
//! not, and the frame rate is capped here instead ([`Pacer`]). Nothing is lost
|
||||||
|
//! by dropping vsync there: a Wayland compositor composites from the last
|
||||||
|
//! buffer a client committed, at its own refresh, so a client that commits too
|
||||||
|
//! often wastes work but cannot tear. X11 and Windows keep vsync, where a
|
||||||
|
//! client really does drive the scanout and hidden windows do not block.
|
||||||
|
//!
|
||||||
|
//! The cap is a ceiling and not a rate: frames are drawn when something asks
|
||||||
|
//! for one — a new mpv frame, an animation, a keystroke — so an idle window
|
||||||
|
//! still draws nothing, and a 24 fps film still draws 24 times a second.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// The default ceiling, in frames a second.
|
||||||
|
///
|
||||||
|
/// Above the rate of any video this plays and of every animation in the app.
|
||||||
|
/// It is deliberately not the display's refresh rate, which neither eframe nor
|
||||||
|
/// egui gives us — a window on a 144 Hz panel therefore animates at 60 while
|
||||||
|
/// vsync is off. [`CAP_VAR`] is the way out for someone who minds.
|
||||||
|
const DEFAULT_CAP: f32 = 60.0;
|
||||||
|
|
||||||
|
/// Overrides [`DEFAULT_CAP`]. `0` removes the ceiling altogether.
|
||||||
|
const CAP_VAR: &str = "PSTR_FRAME_CAP";
|
||||||
|
|
||||||
|
/// Whether this process is talking to a Wayland compositor, decided the way
|
||||||
|
/// winit decides it: either variable, non-empty, Wayland first.
|
||||||
|
pub fn on_wayland() -> bool {
|
||||||
|
["WAYLAND_DISPLAY", "WAYLAND_SOCKET"]
|
||||||
|
.iter()
|
||||||
|
.any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to ask for a swap interval of 1. False on Wayland — see the module
|
||||||
|
/// documentation.
|
||||||
|
pub fn vsync() -> bool {
|
||||||
|
!on_wayland()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ceiling to pace to, or `None` when vsync is doing the pacing.
|
||||||
|
fn cap() -> Option<f32> {
|
||||||
|
if vsync() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Ok(value) = std::env::var(CAP_VAR) else {
|
||||||
|
return Some(DEFAULT_CAP);
|
||||||
|
};
|
||||||
|
// Not a number, or a negative one, is a typo rather than a request to
|
||||||
|
// free-run — free-running is what `0` says, explicitly.
|
||||||
|
let parsed = value
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|fps| fps.is_finite() && *fps >= 0.0);
|
||||||
|
match parsed {
|
||||||
|
Some(fps) if fps > 0.0 => Some(fps),
|
||||||
|
Some(_) => None,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("{CAP_VAR}={value:?} is not a frame rate; using {DEFAULT_CAP}");
|
||||||
|
Some(DEFAULT_CAP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the draw loop to a maximum rate.
|
||||||
|
///
|
||||||
|
/// Sleeping is the only thing that can do this: a frame is under way by the
|
||||||
|
/// time the app sees it, and every other source of repaints — mpv, egui's own
|
||||||
|
/// animations — asks for the *next* one immediately. The sleep is at most one
|
||||||
|
/// frame's worth, which is far inside the several seconds a compositor waits
|
||||||
|
/// for a pong.
|
||||||
|
pub struct Pacer {
|
||||||
|
interval: Option<Duration>,
|
||||||
|
last: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Pacer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pacer {
|
||||||
|
/// The pacer this session wants: capped when vsync is off, a no-op when it
|
||||||
|
/// is on.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_cap(cap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_cap(fps: Option<f32>) -> Self {
|
||||||
|
Self {
|
||||||
|
interval: fps.map(|fps| Duration::from_secs_f32(1.0 / fps)),
|
||||||
|
last: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this pacer does anything at all.
|
||||||
|
pub fn is_capped(&self) -> bool {
|
||||||
|
self.interval.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once at the top of a frame: sleeps out whatever is left of the
|
||||||
|
/// last one's budget.
|
||||||
|
pub fn wait(&mut self) {
|
||||||
|
let Some(interval) = self.interval else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let elapsed = self.last.elapsed();
|
||||||
|
if let Some(left) = interval.checked_sub(elapsed) {
|
||||||
|
std::thread::sleep(left);
|
||||||
|
}
|
||||||
|
self.last = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncapped_pacer_never_sleeps() {
|
||||||
|
let mut pacer = Pacer::with_cap(None);
|
||||||
|
assert!(!pacer.is_capped());
|
||||||
|
let before = Instant::now();
|
||||||
|
pacer.wait();
|
||||||
|
pacer.wait();
|
||||||
|
assert!(before.elapsed() < Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capped_pacer_holds_the_second_frame_back() {
|
||||||
|
let mut pacer = Pacer::with_cap(Some(50.0));
|
||||||
|
assert!(pacer.is_capped());
|
||||||
|
pacer.wait();
|
||||||
|
let before = Instant::now();
|
||||||
|
pacer.wait();
|
||||||
|
// 20 ms apart, less whatever the first call spent. Sleeping is only
|
||||||
|
// ever long, so the lower bound is the one worth asserting on.
|
||||||
|
assert!(
|
||||||
|
before.elapsed() >= Duration::from_millis(10),
|
||||||
|
"second frame was not held back: {:?}",
|
||||||
|
before.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_that_took_longer_than_the_budget_is_not_delayed_further() {
|
||||||
|
let mut pacer = Pacer::with_cap(Some(1000.0));
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
let before = Instant::now();
|
||||||
|
pacer.wait();
|
||||||
|
assert!(before.elapsed() < Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,16 @@ impl PlaybackTarget {
|
|||||||
|
|
||||||
/// The line under the title while this plays: the numbering, and the
|
/// The line under the title while this plays: the numbering, and the
|
||||||
/// episode's name when a provider has given one.
|
/// episode's name when a provider has given one.
|
||||||
|
/// How to name this file to someone not looking at its title page:
|
||||||
|
/// the title and the episode, or the file name when there is no title.
|
||||||
|
pub fn caption_or_name(&self) -> String {
|
||||||
|
match (self.title_name.is_empty(), self.caption()) {
|
||||||
|
(false, caption) if !caption.is_empty() => format!("{} — {caption}", self.title_name),
|
||||||
|
(false, _) => self.title_name.clone(),
|
||||||
|
(true, _) => self.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn caption(&self) -> String {
|
pub fn caption(&self) -> String {
|
||||||
match &self.episode_name {
|
match &self.episode_name {
|
||||||
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
|
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
|
||||||
@@ -120,7 +130,7 @@ impl PlaybackTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What the UI can ask the player to do.
|
/// What the UI can ask the player to do.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
TogglePause,
|
TogglePause,
|
||||||
SeekBy(f64),
|
SeekBy(f64),
|
||||||
@@ -134,6 +144,13 @@ pub enum Command {
|
|||||||
/// every file it loads afterwards, so picking Japanese audio for one
|
/// every file it loads afterwards, so picking Japanese audio for one
|
||||||
/// episode picks it for the next one too.
|
/// episode picks it for the next one too.
|
||||||
SelectTrack(TrackKind, Option<i64>),
|
SelectTrack(TrackKind, Option<i64>),
|
||||||
|
/// Move subtitles, as a percentage of the window's height. See
|
||||||
|
/// [`Playback::lift_subtitles`].
|
||||||
|
SetSubtitlePosition(f64),
|
||||||
|
/// 1.0 is normal speed.
|
||||||
|
SetSpeed(f64),
|
||||||
|
/// Save the current frame to this file.
|
||||||
|
Screenshot(std::path::PathBuf),
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +179,11 @@ pub struct Playback {
|
|||||||
/// 0–100, as mpv has it.
|
/// 0–100, as mpv has it.
|
||||||
pub volume: f64,
|
pub volume: f64,
|
||||||
pub muted: bool,
|
pub muted: bool,
|
||||||
|
/// Playback speed, 1.0 being normal.
|
||||||
|
pub speed: f64,
|
||||||
|
/// Where subtitles were last put, so they are only moved when that changes
|
||||||
|
/// rather than once a frame.
|
||||||
|
subtitle_position: f64,
|
||||||
/// What the file contains. Empty until mpv has demuxed it.
|
/// What the file contains. Empty until mpv has demuxed it.
|
||||||
pub tracks: Vec<Track>,
|
pub tracks: Vec<Track>,
|
||||||
/// The file's chapters, in order. Empty for a file muxed without them.
|
/// The file's chapters, in order. Empty for a file muxed without them.
|
||||||
@@ -179,6 +201,10 @@ pub struct Playback {
|
|||||||
/// A seek was issued and the picture has not come back yet — the thing
|
/// A seek was issued and the picture has not come back yet — the thing
|
||||||
/// worth showing a spinner for on a link this slow.
|
/// worth showing a spinner for on a link this slow.
|
||||||
pub seeking: bool,
|
pub seeking: bool,
|
||||||
|
/// The end of what mpv has read ahead, in seconds from the start.
|
||||||
|
pub buffered: Option<f64>,
|
||||||
|
/// Playback is held waiting for data.
|
||||||
|
pub buffering: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Playback {
|
impl Playback {
|
||||||
@@ -196,10 +222,9 @@ impl Playback {
|
|||||||
target: PlaybackTarget,
|
target: PlaybackTarget,
|
||||||
stream: VideoStream,
|
stream: VideoStream,
|
||||||
gl: Option<&Arc<glow::Context>>,
|
gl: Option<&Arc<glow::Context>>,
|
||||||
ctx: &egui::Context,
|
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let prefs = engine.playback_prefs();
|
let prefs = engine.playback_prefs();
|
||||||
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?;
|
let (player, video) = build_player(engine, &target, &prefs, gl)?;
|
||||||
|
|
||||||
let (commands, orders) = channel::<Command>();
|
let (commands, orders) = channel::<Command>();
|
||||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -234,6 +259,8 @@ impl Playback {
|
|||||||
paused: false,
|
paused: false,
|
||||||
volume: prefs.volume,
|
volume: prefs.volume,
|
||||||
muted: prefs.muted,
|
muted: prefs.muted,
|
||||||
|
subtitle_position: 100.0,
|
||||||
|
speed: prefs.speed,
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
roles: Vec::new(),
|
roles: Vec::new(),
|
||||||
@@ -242,10 +269,30 @@ impl Playback {
|
|||||||
duration: None,
|
duration: None,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
seeking: false,
|
seeking: false,
|
||||||
|
buffered: None,
|
||||||
|
buffering: false,
|
||||||
target,
|
target,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep subtitles clear of `covered` points at the bottom of a picture
|
||||||
|
/// `height` points tall — the controls, while they are up.
|
||||||
|
///
|
||||||
|
/// Without it the line being read sits under the seek bar for as long as
|
||||||
|
/// the mouse is moving, which is exactly when someone is looking at the
|
||||||
|
/// screen.
|
||||||
|
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
|
||||||
|
let position = if covered > 0.0 && height > 0.0 {
|
||||||
|
(100.0 * (1.0 - f64::from(covered / height))).round()
|
||||||
|
} else {
|
||||||
|
100.0
|
||||||
|
};
|
||||||
|
if position != self.subtitle_position {
|
||||||
|
self.subtitle_position = position;
|
||||||
|
self.send(Command::SetSubtitlePosition(position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the picture is drawn in this window rather than mpv's own.
|
/// Whether the picture is drawn in this window rather than mpv's own.
|
||||||
pub fn is_embedded(&self) -> bool {
|
pub fn is_embedded(&self) -> bool {
|
||||||
self.video.is_some()
|
self.video.is_some()
|
||||||
@@ -329,6 +376,8 @@ impl Playback {
|
|||||||
PlayerEvent::Paused(paused) => self.paused = *paused,
|
PlayerEvent::Paused(paused) => self.paused = *paused,
|
||||||
PlayerEvent::Seek => self.seeking = true,
|
PlayerEvent::Seek => self.seeking = true,
|
||||||
PlayerEvent::PlaybackRestart => self.seeking = false,
|
PlayerEvent::PlaybackRestart => self.seeking = false,
|
||||||
|
PlayerEvent::Buffered(end) => self.buffered = Some(*end),
|
||||||
|
PlayerEvent::Buffering(held) => self.buffering = *held,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +414,6 @@ fn build_player(
|
|||||||
target: &PlaybackTarget,
|
target: &PlaybackTarget,
|
||||||
prefs: &PlaybackPrefs,
|
prefs: &PlaybackPrefs,
|
||||||
gl: Option<&Arc<glow::Context>>,
|
gl: Option<&Arc<glow::Context>>,
|
||||||
ctx: &egui::Context,
|
|
||||||
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
|
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
|
||||||
let title = format!("proton-stream — {}", target.name);
|
let title = format!("proton-stream — {}", target.name);
|
||||||
|
|
||||||
@@ -377,6 +425,7 @@ fn build_player(
|
|||||||
// controller would be a second set of controls over the same file.
|
// controller would be a second set of controls over the same file.
|
||||||
on_screen_controller: false,
|
on_screen_controller: false,
|
||||||
default_keybindings: false,
|
default_keybindings: false,
|
||||||
|
hardware_decoding: engine.desktop_prefs().hardware_decoding,
|
||||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -386,8 +435,12 @@ fn build_player(
|
|||||||
// SAFETY: called from `App::ui`, where eframe has made this
|
// SAFETY: called from `App::ui`, where eframe has made this
|
||||||
// context current, and the surface is dropped from the same
|
// context current, and the surface is dropped from the same
|
||||||
// place — see `video.rs`.
|
// place — see `video.rs`.
|
||||||
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) }
|
match unsafe {
|
||||||
{
|
VideoSurface::new(Arc::clone(&player), Arc::clone(gl), {
|
||||||
|
let engine = engine.clone();
|
||||||
|
move || engine.wake()
|
||||||
|
})
|
||||||
|
} {
|
||||||
Ok(video) => return Ok((player, Some(video))),
|
Ok(video) => return Ok((player, Some(video))),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!("embed the video: {error}; falling back to a window");
|
tracing::warn!("embed the video: {error}; falling back to a window");
|
||||||
@@ -406,6 +459,7 @@ fn build_player(
|
|||||||
|
|
||||||
let config = PlayerConfig {
|
let config = PlayerConfig {
|
||||||
window_title: title,
|
window_title: title,
|
||||||
|
hardware_decoding: engine.desktop_prefs().hardware_decoding,
|
||||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||||
};
|
};
|
||||||
let player = Player::new(engine.runtime().clone(), config)
|
let player = Player::new(engine.runtime().clone(), config)
|
||||||
@@ -428,6 +482,9 @@ fn from_prefs(
|
|||||||
.and_then(|p| p.subtitle_language.clone())
|
.and_then(|p| p.subtitle_language.clone())
|
||||||
.or_else(|| prefs.subtitle_language.clone()),
|
.or_else(|| prefs.subtitle_language.clone()),
|
||||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||||
|
// Carried from file to file, like the volume: somebody who watches at
|
||||||
|
// 1.25 watches the next episode at 1.25 too.
|
||||||
|
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
||||||
..PlayerConfig::default()
|
..PlayerConfig::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,8 +544,12 @@ fn run(
|
|||||||
PlayerEvent::FileLoaded => {
|
PlayerEvent::FileLoaded => {
|
||||||
duration = player.duration();
|
duration = player.duration();
|
||||||
// The first moment there is a track list to read: mpv has
|
// The first moment there is a track list to read: mpv has
|
||||||
// demuxed the file and made its own default selection, which
|
// demuxed the file and made its own default selection. Put the
|
||||||
// is what the menus have to open showing.
|
// title's own choice over it before the menus are told, so they
|
||||||
|
// open showing what is actually playing.
|
||||||
|
if let Some(show) = target.track_prefs.as_deref() {
|
||||||
|
restore_tracks(&player, show);
|
||||||
|
}
|
||||||
emit_tracks(&engine, id, &player);
|
emit_tracks(&engine, id, &player);
|
||||||
// Only now is there a timeline to seek within.
|
// Only now is there a timeline to seek within.
|
||||||
if !resumed && let Some(at) = target.resume_at {
|
if !resumed && let Some(at) = target.resume_at {
|
||||||
@@ -565,6 +626,9 @@ fn apply_command(
|
|||||||
Command::SeekTo(seconds) => player.seek_to(seconds),
|
Command::SeekTo(seconds) => player.seek_to(seconds),
|
||||||
Command::SetVolume(volume) => player.set_volume(volume),
|
Command::SetVolume(volume) => player.set_volume(volume),
|
||||||
Command::SetMuted(muted) => player.set_muted(muted),
|
Command::SetMuted(muted) => player.set_muted(muted),
|
||||||
|
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
||||||
|
Command::SetSpeed(speed) => player.set_speed(speed),
|
||||||
|
Command::Screenshot(path) => player.screenshot(&path),
|
||||||
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
||||||
Command::Stop => {
|
Command::Stop => {
|
||||||
let _ = player.quit();
|
let _ = player.quit();
|
||||||
@@ -611,6 +675,44 @@ fn select_track(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put back the tracks this title was last watched with.
|
||||||
|
///
|
||||||
|
/// `alang`/`slang` already steered mpv's pick by language. This is for what a
|
||||||
|
/// language cannot say — which of two English subtitle tracks, or an audio track
|
||||||
|
/// with a name and no tag — so only a remembered *title* acts here; a title with
|
||||||
|
/// nothing but a language keeps mpv's selection, which honours it already.
|
||||||
|
fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
||||||
|
let tracks = player.tracks();
|
||||||
|
let wanted = [
|
||||||
|
(
|
||||||
|
TrackKind::Audio,
|
||||||
|
show.audio_language.as_deref(),
|
||||||
|
show.audio_title.as_deref(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
TrackKind::Subtitle,
|
||||||
|
show.subtitle_language.as_deref(),
|
||||||
|
show.subtitle_title.as_deref(),
|
||||||
|
show.subtitles,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (kind, language, title, enabled) in wanted {
|
||||||
|
if !enabled || title.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let playing = tracks
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected);
|
||||||
|
if !playing && let Err(error) = player.select_track(kind, Some(track)) {
|
||||||
|
tracing::warn!("restore {} track {track}: {error}", kind.label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the UI what the file contains and what of it is playing.
|
/// Tell the UI what the file contains and what of it is playing.
|
||||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||||
engine.emit(Event::Player {
|
engine.emit(Event::Player {
|
||||||
|
|||||||
+457
-369
@@ -23,11 +23,11 @@ use std::collections::BTreeMap;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use egui::{
|
use egui::{
|
||||||
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
||||||
ThemePreference, ViewportCommand, Visuals,
|
ThemePreference, ViewportCommand, Visuals,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
use pstr_core::appearance::Appearance;
|
||||||
|
|
||||||
/// Card geometry. The grid is built from these, so a change here moves
|
/// Card geometry. The grid is built from these, so a change here moves
|
||||||
/// everything together.
|
/// everything together.
|
||||||
@@ -36,26 +36,219 @@ pub const CARD_WIDTH: f32 = 232.0;
|
|||||||
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
||||||
pub const CARD_GAP: f32 = 14.0;
|
pub const CARD_GAP: f32 = 14.0;
|
||||||
|
|
||||||
/// Install a real CJK fallback before the default egui fonts. egui ships a
|
/// The type ramp, named once.
|
||||||
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
|
///
|
||||||
/// CJK families. Native desktops already provide one, so use it when present.
|
/// Every size in the app comes from here. They used to be literals at the call
|
||||||
pub fn install_font_fallbacks(ctx: &egui::Context) {
|
/// site — `.size(17.0)` in one place and `.size(18.0)` in another for the same
|
||||||
|
/// kind of heading — which is not a scale, it is forty independent decisions
|
||||||
|
/// that happen to be close together. Naming them makes the ramp a thing that
|
||||||
|
/// can be changed, and gives the Android client something to match: a shared
|
||||||
|
/// palette settles colour, and this settles the other half.
|
||||||
|
///
|
||||||
|
/// Sizes rather than full `FontId`s because everything here is proportional;
|
||||||
|
/// the one monospace style in the app is egui's own, set in [`apply`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Role {
|
||||||
|
/// The name of the thing you are looking at, once per page.
|
||||||
|
Display,
|
||||||
|
/// An empty state, or the heading of a page that has no display line.
|
||||||
|
Title,
|
||||||
|
/// The brand in the nav bar, and a dialog's heading.
|
||||||
|
Heading,
|
||||||
|
/// A run of content under a rule.
|
||||||
|
Section,
|
||||||
|
/// A row that groups the rows under it — a season header.
|
||||||
|
Subhead,
|
||||||
|
/// Everything that is prose.
|
||||||
|
Body,
|
||||||
|
/// The text on a small control.
|
||||||
|
Label,
|
||||||
|
/// Context rather than content: counts, sizes, errors under a field.
|
||||||
|
Caption,
|
||||||
|
/// The smallest thing that stays legible — a badge over artwork.
|
||||||
|
Micro,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
pub const fn size(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Self::Display => 26.0,
|
||||||
|
Self::Title => 20.0,
|
||||||
|
Self::Heading => 18.0,
|
||||||
|
Self::Section => 17.0,
|
||||||
|
Self::Subhead => 15.0,
|
||||||
|
Self::Body => 14.0,
|
||||||
|
Self::Label => 13.0,
|
||||||
|
Self::Caption => 12.0,
|
||||||
|
Self::Micro => 11.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headings are set in the semibold cut; everything below them in the
|
||||||
|
/// regular one.
|
||||||
|
pub fn family(self) -> egui::FontFamily {
|
||||||
|
match self {
|
||||||
|
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
||||||
|
_ => egui::FontFamily::Proportional,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For the hand-painted parts, which lay text out through a `Painter` and
|
||||||
|
/// never see a `RichText`.
|
||||||
|
pub fn font(self) -> egui::FontId {
|
||||||
|
egui::FontId::new(self.size(), self.family())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For the parts that go through egui's widgets.
|
||||||
|
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
||||||
|
egui::RichText::new(text)
|
||||||
|
.size(self.size())
|
||||||
|
.family(self.family())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spacing scale. Gaps between things come from here rather than being
|
||||||
|
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
|
||||||
|
/// and never 14 in one place and 15 in the next.
|
||||||
|
pub mod space {
|
||||||
|
/// Between lines that belong to one thing: a title and its caption.
|
||||||
|
pub const XXS: f32 = 2.0;
|
||||||
|
pub const XS: f32 = 4.0;
|
||||||
|
/// Between controls in one cluster.
|
||||||
|
pub const S: f32 = 6.0;
|
||||||
|
pub const M: f32 = 8.0;
|
||||||
|
/// Between clusters on one row, and between rows of a form.
|
||||||
|
pub const L: f32 = 12.0;
|
||||||
|
/// Between the groups on a page.
|
||||||
|
pub const XL: f32 = 16.0;
|
||||||
|
/// Between sections.
|
||||||
|
pub const XXL: f32 = 24.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corner radii, as `egui::CornerRadius::same` takes them.
|
||||||
|
pub mod radius {
|
||||||
|
/// A progress bar, or a strip along an edge.
|
||||||
|
pub const BAR: u8 = 3;
|
||||||
|
/// A badge over artwork. Android's `Shape.kt` has the same number.
|
||||||
|
pub const BADGE: u8 = 4;
|
||||||
|
/// A small control inside something else: a menu entry, a chip.
|
||||||
|
pub const SM: u8 = 6;
|
||||||
|
/// Everything else either client paints: cards, buttons, the tab pill.
|
||||||
|
pub const MD: u8 = 8;
|
||||||
|
/// Something that floats: a row card, a dialog, a toast, the hero.
|
||||||
|
pub const LG: u8 = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long things take to change, in seconds, for `Context::animate_*`.
|
||||||
|
pub mod motion {
|
||||||
|
/// A press, which has to answer before the finger is off the button.
|
||||||
|
pub const PRESS: f32 = 0.05;
|
||||||
|
/// A hover arriving and leaving.
|
||||||
|
pub const HOVER: f32 = 0.12;
|
||||||
|
/// A control changing state: a switch flipping, the tab pill travelling,
|
||||||
|
/// one page giving way to the next.
|
||||||
|
pub const STATE: f32 = 0.15;
|
||||||
|
/// Chrome fading in and out: the player's controls, a toast.
|
||||||
|
pub const FADE: f32 = 0.18;
|
||||||
|
/// Artwork arriving over its placeholder. Slower, because it replaces
|
||||||
|
/// something rather than adding to it, and a fast swap reads as a flicker.
|
||||||
|
pub const ARRIVE: f32 = 0.24;
|
||||||
|
|
||||||
|
/// Fast out, slow in: the curve every hand-driven transition uses.
|
||||||
|
pub fn ease_out(t: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI typeface, bundled rather than taken from the system so a title
|
||||||
|
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
||||||
|
/// beside the files.
|
||||||
|
///
|
||||||
|
/// Both files have their private use area mappings removed, because the
|
||||||
|
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
||||||
|
/// own alternate glyphs, and as the first font in the family it won: the
|
||||||
|
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
||||||
|
/// in the files; only the character map entries are gone. Strip them again
|
||||||
|
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
|
||||||
|
/// U+E000..=U+F8FF).
|
||||||
|
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
||||||
|
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
||||||
|
|
||||||
|
/// The family the headings are set in. egui has no notion of weight, so the
|
||||||
|
/// semibold cut is a family of its own.
|
||||||
|
pub fn semibold() -> egui::FontFamily {
|
||||||
|
egui::FontFamily::Name("semibold".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
|
||||||
|
/// symbols Inter does not carry. `cjk` goes last, when there is one.
|
||||||
|
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||||
|
use egui::FontFamily::{Monospace, Proportional};
|
||||||
|
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert("inter".into(), Arc::new(egui::FontData::from_static(INTER)));
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"inter-semibold".into(),
|
||||||
|
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
||||||
|
);
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "inter".into());
|
||||||
|
// Right behind Inter. The icons are in the private use area, which the
|
||||||
|
// bundled Inter no longer maps, so neither font shadows the other.
|
||||||
|
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||||
|
if let Some(cjk) = cjk {
|
||||||
|
let name = "system-cjk-fallback".to_owned();
|
||||||
|
fonts.font_data.insert(name.clone(), Arc::new(cjk));
|
||||||
|
for family in [Proportional, Monospace] {
|
||||||
|
fonts.families.entry(family).or_default().push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fallbacks = fonts.families[&Proportional]
|
||||||
|
.iter()
|
||||||
|
.filter(|name| *name != "inter")
|
||||||
|
.cloned();
|
||||||
|
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
||||||
|
.chain(fallbacks)
|
||||||
|
.collect();
|
||||||
|
fonts.families.insert(semibold(), semibold_chain);
|
||||||
|
fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the bundled fonts now, and a CJK fallback when the system has one.
|
||||||
|
///
|
||||||
|
/// The bundled ones are compiled in and cost nothing to hand over. The CJK
|
||||||
|
/// fallback is on a thread of its own: `fc-match` and reading a font file of
|
||||||
|
/// tens of megabytes held the first frame back by a visible moment, and every
|
||||||
|
/// name that needs the fallback is still loading from the catalog by then
|
||||||
|
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
||||||
|
/// and native desktops already provide one.
|
||||||
|
pub fn install_fonts(ctx: &egui::Context) {
|
||||||
|
ctx.set_fonts(font_definitions(None));
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("fonts".into())
|
||||||
|
.spawn(move || install_cjk_fallback(&ctx));
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
tracing::debug!("font fallback thread: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_cjk_fallback(ctx: &egui::Context) {
|
||||||
let Some((path, index)) = system_cjk_font() else {
|
let Some((path, index)) = system_cjk_font() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(bytes) = std::fs::read(path) else {
|
let Ok(bytes) = std::fs::read(path) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
data.index = index;
|
data.index = index;
|
||||||
let name = "system-cjk-fallback".to_owned();
|
ctx.set_fonts(font_definitions(Some(data)));
|
||||||
fonts.font_data.insert(name.clone(), Arc::new(data));
|
ctx.request_repaint();
|
||||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
|
||||||
fonts.families.entry(family).or_default().push(name.clone());
|
|
||||||
}
|
|
||||||
ctx.set_fonts(fonts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -91,11 +284,12 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every colour the app draws with, resolved from a [`Flavor`] and an
|
/// Every colour the app draws with, as egui wants them.
|
||||||
/// [`Accent`].
|
|
||||||
///
|
///
|
||||||
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
|
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
||||||
/// a drawing loop has to think about.
|
/// with the Android client, so a flavour means the same thing on both. This is
|
||||||
|
/// only that palette converted into `Color32`, which is a type `pstr-core`
|
||||||
|
/// deliberately knows nothing about.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Palette {
|
pub struct Palette {
|
||||||
/// Behind everything: the page.
|
/// Behind everything: the page.
|
||||||
@@ -118,15 +312,54 @@ pub struct Palette {
|
|||||||
/// The accent taken down into the background, for a pressed control and for
|
/// The accent taken down into the background, for a pressed control and for
|
||||||
/// selection behind text.
|
/// selection behind text.
|
||||||
pub accent_dim: Color32,
|
pub accent_dim: Color32,
|
||||||
/// Ink that stays readable on top of the accent. Chosen by the accent's
|
/// Ink that stays readable on top of the accent.
|
||||||
/// luminance, because Catppuccin's dark flavours have *pale* accents and
|
|
||||||
/// white-on-pastel is not readable at 13 px.
|
|
||||||
pub on_accent: Color32,
|
pub on_accent: Color32,
|
||||||
pub danger: Color32,
|
pub danger: Color32,
|
||||||
/// Whether this is a light theme.
|
/// Whether this is a light theme.
|
||||||
pub light: bool,
|
pub light: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn color(rgb: pstr_core::appearance::Rgb) -> Color32 {
|
||||||
|
Color32::from_rgb(rgb.r, rgb.g, rgb.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
/// The default, spelled out so the global has something to hold before
|
||||||
|
/// [`apply`] first runs — a `Ui` built during startup, or a panic message
|
||||||
|
/// painted before the engine exists, still gets colours.
|
||||||
|
const PROTON: Self = Self::of(pstr_core::appearance::Palette::PROTON);
|
||||||
|
|
||||||
|
const fn of(palette: pstr_core::appearance::Palette) -> Self {
|
||||||
|
Self {
|
||||||
|
background: color(palette.background),
|
||||||
|
surface: color(palette.surface),
|
||||||
|
sunken: color(palette.sunken),
|
||||||
|
card: color(palette.card),
|
||||||
|
card_hover: color(palette.card_hover),
|
||||||
|
border: color(palette.border),
|
||||||
|
text: color(palette.text),
|
||||||
|
muted: color(palette.muted),
|
||||||
|
accent: color(palette.accent),
|
||||||
|
accent_alt: color(palette.accent_alt),
|
||||||
|
accent_dim: color(palette.accent_dim),
|
||||||
|
on_accent: color(palette.on_accent),
|
||||||
|
danger: color(palette.danger),
|
||||||
|
light: palette.light,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a choice into colours.
|
||||||
|
pub fn resolve(appearance: Appearance) -> Self {
|
||||||
|
Self::of(pstr_core::appearance::Palette::resolve(appearance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blend two colours in linear light, through the shared implementation.
|
||||||
|
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
||||||
|
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
|
||||||
|
color(pstr_core::appearance::mix(rgb(from), rgb(to), t))
|
||||||
|
}
|
||||||
|
|
||||||
/// Which way a gradient runs.
|
/// Which way a gradient runs.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Direction {
|
pub enum Direction {
|
||||||
@@ -170,6 +403,16 @@ pub fn danger() -> Color32 {
|
|||||||
palette().danger
|
palette().danger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether ramps are drawn at all.
|
||||||
|
///
|
||||||
|
/// Wider than [`gradients`], which also asks whether the *accent* has two hues
|
||||||
|
/// to run between. The viewer who turns gradients off is usually on a panel that
|
||||||
|
/// bands them into stripes, and that applies to every ramp in the app, not only
|
||||||
|
/// the accent's — so the bar fade and the title backdrop ask this one.
|
||||||
|
pub fn ramps_on() -> bool {
|
||||||
|
ACTIVE.read().gradients
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the accent is currently drawn as a gradient.
|
/// Whether the accent is currently drawn as a gradient.
|
||||||
///
|
///
|
||||||
/// True only when the viewer has gradients on *and* the accent is a pair of
|
/// True only when the viewer has gradients on *and* the accent is a pair of
|
||||||
@@ -197,6 +440,29 @@ pub fn accent_fill(
|
|||||||
veil(painter, rect, corner_radius, lift);
|
veil(painter, rect, corner_radius, lift);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The accent as a shape rather than paint, for the one caller that has to
|
||||||
|
/// draw it *behind* something already laid out: the pill that slides between
|
||||||
|
/// the tabs in the navigation bar, whose position is only known once every tab
|
||||||
|
/// has been placed.
|
||||||
|
pub fn accent_shape(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
rect: Rect,
|
||||||
|
corner_radius: impl Into<CornerRadius>,
|
||||||
|
) -> egui::Shape {
|
||||||
|
let corner_radius = corner_radius.into();
|
||||||
|
let palette = palette();
|
||||||
|
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
|
||||||
|
return egui::epaint::RectShape::filled(rect, corner_radius, palette.accent).into();
|
||||||
|
}
|
||||||
|
let texture = ramp(
|
||||||
|
ctx,
|
||||||
|
palette.accent,
|
||||||
|
palette.accent_alt,
|
||||||
|
Direction::Horizontal,
|
||||||
|
);
|
||||||
|
textured(rect, corner_radius, &texture)
|
||||||
|
}
|
||||||
|
|
||||||
/// The same, for a palette that is not the one on screen: the accent swatches
|
/// The same, for a palette that is not the one on screen: the accent swatches
|
||||||
/// on the settings page, each of which previews the theme clicking it would
|
/// on the settings page, each of which previews the theme clicking it would
|
||||||
/// give.
|
/// give.
|
||||||
@@ -225,6 +491,57 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
|
|||||||
painter.add(textured(rect, corner_radius, &texture));
|
painter.add(textured(rect, corner_radius, &texture));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rounded rectangle running from one colour to another, at `opacity`.
|
||||||
|
///
|
||||||
|
/// For colours that are not the palette's — a title's own, on a tile with no
|
||||||
|
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
|
||||||
|
pub fn ramp_fill(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: Rect,
|
||||||
|
corner_radius: CornerRadius,
|
||||||
|
(from, to): (Color32, Color32),
|
||||||
|
direction: Direction,
|
||||||
|
opacity: f32,
|
||||||
|
) {
|
||||||
|
let tint = Color32::WHITE.gamma_multiply(opacity);
|
||||||
|
if !ramps_on() {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
corner_radius,
|
||||||
|
mix(from, to, 0.5).gamma_multiply(opacity),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let texture = ramp(painter.ctx(), from, to, direction);
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
|
||||||
|
texture.id(),
|
||||||
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ramp from `color` to nothing, for laying artwork into the page.
|
||||||
|
///
|
||||||
|
/// The other ramps here run between two opaque colours; this one runs down its
|
||||||
|
/// own alpha, so what is under it shows through the far end. That is what makes
|
||||||
|
/// a backdrop end in the page rather than in a horizontal line across the
|
||||||
|
/// window.
|
||||||
|
/// `arriving` is which end of `direction` the colour is solid at: `false` for a
|
||||||
|
/// scrim that starts opaque and lets the picture out, `true` for one that lets
|
||||||
|
/// the picture *into* the page — which is the one the title backdrop wants,
|
||||||
|
/// since the page is below the band.
|
||||||
|
pub fn fade_shape(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
rect: Rect,
|
||||||
|
color: Color32,
|
||||||
|
direction: Direction,
|
||||||
|
arriving: bool,
|
||||||
|
) -> egui::Shape {
|
||||||
|
let texture = alpha_ramp(ctx, color, direction, arriving);
|
||||||
|
textured(rect, CornerRadius::ZERO, &texture)
|
||||||
|
}
|
||||||
|
|
||||||
/// The one gradient that is not the accent: the top bar, which runs from the
|
/// The one gradient that is not the accent: the top bar, which runs from the
|
||||||
/// surface colour down into the page so that the join between them is a fade
|
/// surface colour down into the page so that the join between them is a fade
|
||||||
/// rather than an edge.
|
/// rather than an edge.
|
||||||
@@ -249,6 +566,56 @@ pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape {
|
|||||||
textured(rect, CornerRadius::ZERO, &texture)
|
textured(rect, CornerRadius::ZERO, &texture)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shadow under a raised tile, at `strength` of its full depth.
|
||||||
|
///
|
||||||
|
/// Depth is the one separator this app has been doing without: everything is
|
||||||
|
/// told apart by fill colour, which works until two things that should be at
|
||||||
|
/// different heights are the same colour. Kept soft and offset downwards —
|
||||||
|
/// a shadow you can see the edge of reads as a border.
|
||||||
|
///
|
||||||
|
/// Scaled rather than switched on, so it can ride the same hover value as the
|
||||||
|
/// border and arrive with it.
|
||||||
|
pub fn tile_shadow(strength: f32) -> egui::epaint::Shadow {
|
||||||
|
shadow(strength, [0, 6], 16, 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shadow a bar casts onto the page: shallower than a tile's, because a bar
|
||||||
|
/// is not floating over the page so much as sitting against it.
|
||||||
|
///
|
||||||
|
/// `cast_down` for the bar at the top of the window, off for the transport bar
|
||||||
|
/// at the bottom — a shadow that falls the same way on both would light the
|
||||||
|
/// window from two directions at once.
|
||||||
|
pub fn bar_shadow(cast_down: bool) -> egui::epaint::Shadow {
|
||||||
|
shadow(1.0, [0, if cast_down { 4 } else { -4 }], 12, 70)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::Shadow {
|
||||||
|
let strength = strength.clamp(0.0, 1.0);
|
||||||
|
egui::epaint::Shadow {
|
||||||
|
offset,
|
||||||
|
blur,
|
||||||
|
spread: 0,
|
||||||
|
// A light theme cannot carry the same shadow: the same black over a
|
||||||
|
// near-white page is a smudge, where over a dark one it is barely
|
||||||
|
// visible. Half strength on light, which is roughly where the two read
|
||||||
|
// the same.
|
||||||
|
color: Color32::from_black_alpha(
|
||||||
|
(f32::from(alpha) * strength * if palette().light { 0.5 } else { 1.0 }) as u8,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hover and press film on its own, for a control whose fill was painted by
|
||||||
|
/// somebody else — the selected tab, which sits on the pill.
|
||||||
|
pub fn veil_over(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: Rect,
|
||||||
|
corner_radius: impl Into<CornerRadius>,
|
||||||
|
lift: f32,
|
||||||
|
) {
|
||||||
|
veil(painter, rect, corner_radius.into(), lift);
|
||||||
|
}
|
||||||
|
|
||||||
/// A white or black film over `rect`, for hover and press states. A no-op at
|
/// A white or black film over `rect`, for hover and press states. A no-op at
|
||||||
/// zero, which is the case that matters — most controls are at rest.
|
/// zero, which is the case that matters — most controls are at rest.
|
||||||
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
|
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
|
||||||
@@ -320,7 +687,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
|||||||
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
||||||
visuals.hyperlink_color = palette.accent;
|
visuals.hyperlink_color = palette.accent;
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(radius::MD);
|
||||||
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
||||||
visuals.widgets.noninteractive.corner_radius = radius;
|
visuals.widgets.noninteractive.corner_radius = radius;
|
||||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
||||||
@@ -351,13 +718,14 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
|||||||
style.spacing.interact_size.y = 30.0;
|
style.spacing.interact_size.y = 30.0;
|
||||||
style.spacing.scroll.bar_width = 10.0;
|
style.spacing.scroll.bar_width = 10.0;
|
||||||
|
|
||||||
use egui::FontFamily::Proportional;
|
// egui's own style names, pointed at the ramp above so that a stock
|
||||||
|
// widget nobody restyled still lands on it.
|
||||||
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
|
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
|
||||||
style.text_styles = [
|
style.text_styles = [
|
||||||
(Heading, egui::FontId::new(26.0, Proportional)),
|
(Heading, Role::Display.font()),
|
||||||
(Body, egui::FontId::new(14.0, Proportional)),
|
(Body, Role::Body.font()),
|
||||||
(Button, egui::FontId::new(14.0, Proportional)),
|
(Button, Role::Body.font()),
|
||||||
(Small, egui::FontId::new(11.5, Proportional)),
|
(Small, Role::Caption.font()),
|
||||||
(
|
(
|
||||||
Monospace,
|
Monospace,
|
||||||
egui::FontId::new(12.5, egui::FontFamily::Monospace),
|
egui::FontId::new(12.5, egui::FontFamily::Monospace),
|
||||||
@@ -390,7 +758,10 @@ struct Active {
|
|||||||
/// flavour the window is not wearing are eight ramps that belong to no
|
/// flavour the window is not wearing are eight ramps that belong to no
|
||||||
/// palette in particular. Bounded by the number of flavours times the
|
/// palette in particular. Bounded by the number of flavours times the
|
||||||
/// number of accents, and only the ones actually drawn.
|
/// number of accents, and only the ones actually drawn.
|
||||||
ramps: BTreeMap<(u32, u32, bool), TextureHandle>,
|
/// The last field separates a ramp between two colours from one that runs
|
||||||
|
/// down its own alpha — the two would otherwise collide on
|
||||||
|
/// `(colour, transparent)`.
|
||||||
|
ramps: BTreeMap<(u32, u32, bool, u8), TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
||||||
@@ -401,17 +772,54 @@ static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
|||||||
|
|
||||||
/// A ramp between two colours, built once and kept.
|
/// A ramp between two colours, built once and kept.
|
||||||
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
|
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
|
||||||
|
build(ctx, from, to, direction, 0, mix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ramp from `color` down to nothing.
|
||||||
|
///
|
||||||
|
/// The alpha is what is interpolated; the colour stays put. Fading the colour
|
||||||
|
/// as well would take a dark scrim through grey on its way out, which over
|
||||||
|
/// artwork reads as fog rather than as the picture ending.
|
||||||
|
fn alpha_ramp(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
color: Color32,
|
||||||
|
direction: Direction,
|
||||||
|
arriving: bool,
|
||||||
|
) -> TextureHandle {
|
||||||
|
build(
|
||||||
|
ctx,
|
||||||
|
color,
|
||||||
|
Color32::TRANSPARENT,
|
||||||
|
direction,
|
||||||
|
1 + u8::from(arriving),
|
||||||
|
move |from, _, t| from.gamma_multiply(if arriving { t } else { 1.0 - t }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One texture per ramp, built on demand and kept until the palette changes.
|
||||||
|
///
|
||||||
|
/// `kind` says which of the three kinds this is, so that a fade and a colour
|
||||||
|
/// ramp between the same two ends do not come out sharing one texture.
|
||||||
|
fn build(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
from: Color32,
|
||||||
|
to: Color32,
|
||||||
|
direction: Direction,
|
||||||
|
kind: u8,
|
||||||
|
stop: impl Fn(Color32, Color32, f32) -> Color32,
|
||||||
|
) -> TextureHandle {
|
||||||
let key = (
|
let key = (
|
||||||
u32::from_le_bytes(from.to_array()),
|
u32::from_le_bytes(from.to_array()),
|
||||||
u32::from_le_bytes(to.to_array()),
|
u32::from_le_bytes(to.to_array()),
|
||||||
direction == Direction::Vertical,
|
direction == Direction::Vertical,
|
||||||
|
kind,
|
||||||
);
|
);
|
||||||
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
|
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
|
||||||
return texture.clone();
|
return texture.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pixels: Vec<Color32> = (0..RAMP_STOPS)
|
let pixels: Vec<Color32> = (0..RAMP_STOPS)
|
||||||
.map(|stop| mix(from, to, stop as f32 / (RAMP_STOPS - 1) as f32))
|
.map(|index| stop(from, to, index as f32 / (RAMP_STOPS - 1) as f32))
|
||||||
.collect();
|
.collect();
|
||||||
let size = match direction {
|
let size = match direction {
|
||||||
Direction::Horizontal => [RAMP_STOPS, 1],
|
Direction::Horizontal => [RAMP_STOPS, 1],
|
||||||
@@ -437,353 +845,31 @@ fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -
|
|||||||
/// light, which is what this does.
|
/// light, which is what this does.
|
||||||
const RAMP_STOPS: usize = 64;
|
const RAMP_STOPS: usize = 64;
|
||||||
|
|
||||||
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
|
|
||||||
/// ramp between saturated hues, which is exactly where a gradient is looked at.
|
|
||||||
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
|
||||||
let from = Rgba::from(from);
|
|
||||||
let to = Rgba::from(to);
|
|
||||||
Color32::from(from * (1.0 - t) + to * t)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Relative luminance, in linear light, as WCAG defines it.
|
|
||||||
fn luminance(color: Color32) -> f32 {
|
|
||||||
let rgba = Rgba::from(color);
|
|
||||||
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
|
||||||
/// 21.0 for black against white.
|
|
||||||
fn contrast(one: Color32, other: Color32) -> f32 {
|
|
||||||
let (one, other) = (luminance(one), luminance(other));
|
|
||||||
(one.max(other) + 0.05) / (one.min(other) + 0.05)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The least contrast a label gets anywhere along a gradient between two
|
|
||||||
/// colours. Sampled rather than solved: the worst point is not always an end,
|
|
||||||
/// because the ink can be brighter than one end and darker than the other.
|
|
||||||
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
|
|
||||||
(0..=4)
|
|
||||||
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
|
|
||||||
.fold(f32::INFINITY, f32::min)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The least contrast the app will accept between a label and the fill under
|
|
||||||
/// it: WCAG AA for large text, which is what wears the accent — button labels,
|
|
||||||
/// a tab, a play glyph, never body copy.
|
|
||||||
const MIN_CONTRAST: f32 = 3.0;
|
|
||||||
|
|
||||||
/// One flavour's raw colours, named as Catppuccin names them.
|
|
||||||
///
|
|
||||||
/// Stored rather than computed: these are somebody else's palette, and the
|
|
||||||
/// point of using it is to use it exactly.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct Ramp {
|
|
||||||
base: Color32,
|
|
||||||
mantle: Color32,
|
|
||||||
crust: Color32,
|
|
||||||
surface0: Color32,
|
|
||||||
surface1: Color32,
|
|
||||||
subtext0: Color32,
|
|
||||||
text: Color32,
|
|
||||||
pink: Color32,
|
|
||||||
mauve: Color32,
|
|
||||||
sky: Color32,
|
|
||||||
sapphire: Color32,
|
|
||||||
blue: Color32,
|
|
||||||
lavender: Color32,
|
|
||||||
teal: Color32,
|
|
||||||
green: Color32,
|
|
||||||
peach: Color32,
|
|
||||||
yellow: Color32,
|
|
||||||
red: Color32,
|
|
||||||
light: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn rgb(hex: u32) -> Color32 {
|
|
||||||
Color32::from_rgb(
|
|
||||||
((hex >> 16) & 0xff) as u8,
|
|
||||||
((hex >> 8) & 0xff) as u8,
|
|
||||||
(hex & 0xff) as u8,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The palette this app shipped with, extended to the hues an accent can be.
|
|
||||||
///
|
|
||||||
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
|
|
||||||
/// chosen to sit on a near-black base at roughly the saturation that one does,
|
|
||||||
/// which is a good deal hotter than any Catppuccin flavour.
|
|
||||||
const PROTON: Ramp = Ramp {
|
|
||||||
base: rgb(0x0e0e12),
|
|
||||||
mantle: rgb(0x17171d),
|
|
||||||
crust: rgb(0x0a0a0d),
|
|
||||||
surface0: rgb(0x1e1e26),
|
|
||||||
surface1: rgb(0x2a2a35),
|
|
||||||
subtext0: rgb(0x8e8e9c),
|
|
||||||
text: rgb(0xeaeaf0),
|
|
||||||
pink: rgb(0xff5fbe),
|
|
||||||
mauve: rgb(0x7d4dff),
|
|
||||||
sky: rgb(0x4dd2ff),
|
|
||||||
sapphire: rgb(0x3a8fc4),
|
|
||||||
blue: rgb(0x3f6fe0),
|
|
||||||
lavender: rgb(0xa68cff),
|
|
||||||
teal: rgb(0x3fd6b8),
|
|
||||||
green: rgb(0x56d364),
|
|
||||||
peach: rgb(0xff9a4d),
|
|
||||||
yellow: rgb(0xffd166),
|
|
||||||
red: rgb(0xe05561),
|
|
||||||
light: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const LATTE: Ramp = Ramp {
|
|
||||||
base: rgb(0xeff1f5),
|
|
||||||
mantle: rgb(0xe6e9ef),
|
|
||||||
crust: rgb(0xdce0e8),
|
|
||||||
surface0: rgb(0xccd0da),
|
|
||||||
surface1: rgb(0xbcc0cc),
|
|
||||||
subtext0: rgb(0x6c6f85),
|
|
||||||
text: rgb(0x4c4f69),
|
|
||||||
pink: rgb(0xea76cb),
|
|
||||||
mauve: rgb(0x8839ef),
|
|
||||||
sky: rgb(0x04a5e5),
|
|
||||||
sapphire: rgb(0x209fb5),
|
|
||||||
blue: rgb(0x1e66f5),
|
|
||||||
lavender: rgb(0x7287fd),
|
|
||||||
teal: rgb(0x179299),
|
|
||||||
green: rgb(0x40a02b),
|
|
||||||
peach: rgb(0xfe640b),
|
|
||||||
yellow: rgb(0xdf8e1d),
|
|
||||||
red: rgb(0xd20f39),
|
|
||||||
light: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FRAPPE: Ramp = Ramp {
|
|
||||||
base: rgb(0x303446),
|
|
||||||
mantle: rgb(0x292c3c),
|
|
||||||
crust: rgb(0x232634),
|
|
||||||
surface0: rgb(0x414559),
|
|
||||||
surface1: rgb(0x51576d),
|
|
||||||
subtext0: rgb(0xa5adce),
|
|
||||||
text: rgb(0xc6d0f5),
|
|
||||||
pink: rgb(0xf4b8e4),
|
|
||||||
mauve: rgb(0xca9ee6),
|
|
||||||
sky: rgb(0x99d1db),
|
|
||||||
sapphire: rgb(0x85c1dc),
|
|
||||||
blue: rgb(0x8caaee),
|
|
||||||
lavender: rgb(0xbabbf1),
|
|
||||||
teal: rgb(0x81c8be),
|
|
||||||
green: rgb(0xa6d189),
|
|
||||||
peach: rgb(0xef9f76),
|
|
||||||
yellow: rgb(0xe5c890),
|
|
||||||
red: rgb(0xe78284),
|
|
||||||
light: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MACCHIATO: Ramp = Ramp {
|
|
||||||
base: rgb(0x24273a),
|
|
||||||
mantle: rgb(0x1e2030),
|
|
||||||
crust: rgb(0x181926),
|
|
||||||
surface0: rgb(0x363a4f),
|
|
||||||
surface1: rgb(0x494d64),
|
|
||||||
subtext0: rgb(0xa5adcb),
|
|
||||||
text: rgb(0xcad3f5),
|
|
||||||
pink: rgb(0xf5bde6),
|
|
||||||
mauve: rgb(0xc6a0f6),
|
|
||||||
sky: rgb(0x91d7e3),
|
|
||||||
sapphire: rgb(0x7dc4e4),
|
|
||||||
blue: rgb(0x8aadf4),
|
|
||||||
lavender: rgb(0xb7bdf8),
|
|
||||||
teal: rgb(0x8bd5ca),
|
|
||||||
green: rgb(0xa6da95),
|
|
||||||
peach: rgb(0xf5a97f),
|
|
||||||
yellow: rgb(0xeed49f),
|
|
||||||
red: rgb(0xed8796),
|
|
||||||
light: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCHA: Ramp = Ramp {
|
|
||||||
base: rgb(0x1e1e2e),
|
|
||||||
mantle: rgb(0x181825),
|
|
||||||
crust: rgb(0x11111b),
|
|
||||||
surface0: rgb(0x313244),
|
|
||||||
surface1: rgb(0x45475a),
|
|
||||||
subtext0: rgb(0xa6adc8),
|
|
||||||
text: rgb(0xcdd6f4),
|
|
||||||
pink: rgb(0xf5c2e7),
|
|
||||||
mauve: rgb(0xcba6f7),
|
|
||||||
sky: rgb(0x89dceb),
|
|
||||||
sapphire: rgb(0x74c7ec),
|
|
||||||
blue: rgb(0x89b4fa),
|
|
||||||
lavender: rgb(0xb4befe),
|
|
||||||
teal: rgb(0x94e2d5),
|
|
||||||
green: rgb(0xa6e3a1),
|
|
||||||
peach: rgb(0xfab387),
|
|
||||||
yellow: rgb(0xf9e2af),
|
|
||||||
red: rgb(0xf38ba8),
|
|
||||||
light: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl Ramp {
|
|
||||||
const fn of(flavor: Flavor) -> Self {
|
|
||||||
match flavor {
|
|
||||||
Flavor::Proton => PROTON,
|
|
||||||
Flavor::Latte => LATTE,
|
|
||||||
Flavor::Frappe => FRAPPE,
|
|
||||||
Flavor::Macchiato => MACCHIATO,
|
|
||||||
Flavor::Mocha => MOCHA,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The accent, and the hue a gradient in it runs into.
|
|
||||||
///
|
|
||||||
/// The partners are neighbours on the wheel rather than complements: a
|
|
||||||
/// gradient across half the spectrum passes through a colour that belongs
|
|
||||||
/// to neither end, and on a seek bar that reads as a bug. The exception is
|
|
||||||
/// [`Accent::PinkSky`], which is the whole point of that entry.
|
|
||||||
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
|
|
||||||
match accent {
|
|
||||||
Accent::Mauve => (self.mauve, self.blue),
|
|
||||||
Accent::Pink => (self.pink, self.mauve),
|
|
||||||
Accent::Sky => (self.sky, self.sapphire),
|
|
||||||
Accent::PinkSky => (self.pink, self.sky),
|
|
||||||
Accent::Lavender => (self.lavender, self.blue),
|
|
||||||
Accent::Blue => (self.blue, self.sapphire),
|
|
||||||
Accent::Teal => (self.teal, self.green),
|
|
||||||
Accent::Peach => (self.peach, self.yellow),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Palette {
|
|
||||||
/// The default, spelled out as a constant so the global has something to
|
|
||||||
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
|
|
||||||
/// panic message painted before the engine exists, still gets colours.
|
|
||||||
const PROTON: Self = Self {
|
|
||||||
background: PROTON.base,
|
|
||||||
surface: PROTON.mantle,
|
|
||||||
sunken: PROTON.crust,
|
|
||||||
card: PROTON.surface0,
|
|
||||||
card_hover: PROTON.surface1,
|
|
||||||
border: rgb(0x262630),
|
|
||||||
text: PROTON.text,
|
|
||||||
muted: PROTON.subtext0,
|
|
||||||
accent: PROTON.mauve,
|
|
||||||
accent_alt: PROTON.blue,
|
|
||||||
accent_dim: rgb(0x5333ad),
|
|
||||||
on_accent: PROTON.text,
|
|
||||||
danger: PROTON.red,
|
|
||||||
light: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Resolve a choice into colours.
|
|
||||||
pub fn resolve(appearance: Appearance) -> Self {
|
|
||||||
let ramp = Ramp::of(appearance.flavor);
|
|
||||||
let (accent, partner) = ramp.accents(appearance.accent);
|
|
||||||
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
|
|
||||||
// at body-text weight — which makes them mid-luminance, and a
|
|
||||||
// mid-luminance fill is one that neither black nor white sits on. They
|
|
||||||
// are taken down in value here, and only here: the hue and the
|
|
||||||
// saturation are Latte's, so it still looks like Latte, and a label on
|
|
||||||
// a button is legible.
|
|
||||||
let deepen = |color: Color32| {
|
|
||||||
if ramp.light {
|
|
||||||
mix(color, Color32::BLACK, 0.30)
|
|
||||||
} else {
|
|
||||||
color
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let accent = deepen(accent);
|
|
||||||
let accent_alt = if appearance.gradients {
|
|
||||||
deepen(partner)
|
|
||||||
} else {
|
|
||||||
accent
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ink that survives on the accent. The flavour's own reading first —
|
|
||||||
// pale text on a dark theme — and the opposite when that does not clear
|
|
||||||
// the bar, which on Catppuccin's dark flavours is most of the time:
|
|
||||||
// their accents are *pastel*, and white on #f5c2e7 is not a label.
|
|
||||||
//
|
|
||||||
// Latte's darkest colour is its body text, and even that is only #4c4f69,
|
|
||||||
// so the dark side is taken a third of the way to black.
|
|
||||||
let ink_dark = if ramp.light {
|
|
||||||
mix(ramp.text, Color32::BLACK, 0.35)
|
|
||||||
} else {
|
|
||||||
ramp.crust
|
|
||||||
};
|
|
||||||
let ink_light = if ramp.light { ramp.base } else { ramp.text };
|
|
||||||
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
|
|
||||||
ink_light
|
|
||||||
} else {
|
|
||||||
ink_dark
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
background: ramp.base,
|
|
||||||
surface: ramp.mantle,
|
|
||||||
sunken: ramp.crust,
|
|
||||||
card: ramp.surface0,
|
|
||||||
card_hover: ramp.surface1,
|
|
||||||
border: ramp.surface1,
|
|
||||||
text: ramp.text,
|
|
||||||
muted: ramp.subtext0,
|
|
||||||
accent,
|
|
||||||
accent_alt,
|
|
||||||
// Behind text, so it is the accent taken most of the way back to
|
|
||||||
// the page: a selection in a saturated hue is a selection nobody
|
|
||||||
// can read through.
|
|
||||||
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
|
|
||||||
on_accent,
|
|
||||||
danger: ramp.red,
|
|
||||||
light: ramp.light,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_flavour_and_accent_resolves_to_readable_ink() {
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||||
for flavor in Flavor::ALL {
|
use skrifa::MetadataProvider as _;
|
||||||
for accent in Accent::ALL {
|
|
||||||
let palette = Palette::resolve(Appearance {
|
|
||||||
flavor,
|
|
||||||
accent,
|
|
||||||
gradients: true,
|
|
||||||
});
|
|
||||||
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
|
||||||
assert!(
|
|
||||||
ratio >= MIN_CONTRAST,
|
|
||||||
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
||||||
fn only_the_light_flavour_reports_itself_light() {
|
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
||||||
for flavor in Flavor::ALL {
|
let shadowed: Vec<u32> = font
|
||||||
let palette = Palette::resolve(Appearance {
|
.charmap()
|
||||||
flavor,
|
.mappings()
|
||||||
..Appearance::default()
|
.map(|(codepoint, _)| codepoint)
|
||||||
});
|
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
|
||||||
assert_eq!(palette.light, flavor.is_light());
|
.collect();
|
||||||
// And the page is on the right side of the middle either way.
|
assert!(
|
||||||
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
|
shadowed.is_empty(),
|
||||||
|
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
|
||||||
|
shadowed.len(),
|
||||||
|
shadowed[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn turning_gradients_off_leaves_one_colour_to_draw() {
|
|
||||||
let flat = Palette::resolve(Appearance {
|
|
||||||
accent: Accent::PinkSky,
|
|
||||||
gradients: false,
|
|
||||||
..Appearance::default()
|
|
||||||
});
|
|
||||||
assert_eq!(flat.accent, flat.accent_alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
||||||
let resolved = Palette::resolve(Appearance::default());
|
let resolved = Palette::resolve(Appearance::default());
|
||||||
@@ -795,11 +881,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_ramp_runs_from_one_colour_to_the_other() {
|
fn a_ramp_runs_from_one_colour_to_the_other() {
|
||||||
|
// The blend itself is `pstr_core::appearance`'s and tested there; what
|
||||||
|
// is checked here is that the conversion in and out of `Color32` is
|
||||||
|
// not lossy at the ends.
|
||||||
let from = Color32::from_rgb(0, 0, 0);
|
let from = Color32::from_rgb(0, 0, 0);
|
||||||
let to = Color32::from_rgb(255, 255, 255);
|
let to = Color32::from_rgb(255, 255, 255);
|
||||||
assert_eq!(mix(from, to, 0.0), from);
|
assert_eq!(mix(from, to, 0.0), from);
|
||||||
assert_eq!(mix(from, to, 1.0), to);
|
assert_eq!(mix(from, to, 1.0), to);
|
||||||
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
|
assert_ne!(mix(from, to, 0.5), from);
|
||||||
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+197
-131
@@ -5,70 +5,120 @@ use crate::engine::{DownloadItem, DownloadState};
|
|||||||
use crate::{theme, ui};
|
use crate::{theme, ui};
|
||||||
|
|
||||||
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
|
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
|
||||||
ui.heading("Downloads");
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
|
|
||||||
));
|
|
||||||
ui.add_space(12.0);
|
|
||||||
|
|
||||||
if downloads.is_empty() {
|
if downloads.is_empty() {
|
||||||
ui.label("No offline downloads.");
|
ui::empty_state(
|
||||||
|
ui,
|
||||||
|
"No downloads",
|
||||||
|
"Download an episode, a season or a whole show from its page, and it plays \
|
||||||
|
without a connection.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut start = 0;
|
egui::ScrollArea::vertical()
|
||||||
while start < downloads.len() {
|
.auto_shrink([false, false])
|
||||||
let title_key = &downloads[start].target.title_key;
|
.show(ui, |ui| {
|
||||||
let title = &downloads[start].target.title_name;
|
ui.set_max_width(960.0);
|
||||||
let end = downloads[start..]
|
summary(ui, downloads);
|
||||||
.iter()
|
ui.add_space(theme::space::L);
|
||||||
.position(|item| item.target.title_key != *title_key)
|
|
||||||
.map_or(downloads.len(), |offset| start + offset);
|
|
||||||
let group = &downloads[start..end];
|
|
||||||
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
|
|
||||||
let total: u64 = group.iter().map(|item| item.total).sum();
|
|
||||||
let completed = group
|
|
||||||
.iter()
|
|
||||||
.filter(|item| item.state == DownloadState::Completed)
|
|
||||||
.count();
|
|
||||||
|
|
||||||
ui.label(egui::RichText::new(title).size(17.0).strong());
|
let mut start = 0;
|
||||||
let totals_known = group.iter().all(|item| item.total > 0);
|
while start < downloads.len() {
|
||||||
ui.horizontal(|ui| {
|
let title_key = &downloads[start].target.title_key;
|
||||||
if totals_known {
|
let end = downloads[start..]
|
||||||
ui.add(
|
.iter()
|
||||||
egui::ProgressBar::new(fraction(downloaded, total))
|
.position(|item| item.target.title_key != *title_key)
|
||||||
.desired_width(260.0)
|
.map_or(downloads.len(), |offset| start + offset);
|
||||||
.show_percentage(),
|
group(ui, &downloads[start..end], actions);
|
||||||
);
|
ui.add_space(theme::space::L);
|
||||||
} else {
|
start = end;
|
||||||
ui.add(egui::Spinner::new().size(16.0));
|
|
||||||
ui.label(ui::muted("Calculating size…"));
|
|
||||||
}
|
}
|
||||||
ui.label(ui::muted(format!(
|
|
||||||
"{} / {} · {completed}/{} files",
|
|
||||||
bytes(downloaded),
|
|
||||||
if !totals_known {
|
|
||||||
"calculating…".to_owned()
|
|
||||||
} else {
|
|
||||||
bytes(total)
|
|
||||||
},
|
|
||||||
group.len()
|
|
||||||
)));
|
|
||||||
});
|
});
|
||||||
for item in group {
|
}
|
||||||
row(ui, item, actions);
|
|
||||||
|
/// The line under the heading: how much, how fast, how long.
|
||||||
|
fn summary(ui: &mut egui::Ui, downloads: &[DownloadItem]) {
|
||||||
|
ui::section(ui, "Downloads");
|
||||||
|
let running: Vec<_> = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Running)
|
||||||
|
.collect();
|
||||||
|
let rate: f64 = running.iter().map(|item| item.rate).sum();
|
||||||
|
let left: u64 = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
matches!(
|
||||||
|
item.state,
|
||||||
|
DownloadState::Queued | DownloadState::Running | DownloadState::Paused
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|item| item.total.saturating_sub(item.downloaded))
|
||||||
|
.sum();
|
||||||
|
let mut parts = vec![format!(
|
||||||
|
"{} available offline",
|
||||||
|
ui::library::plural(
|
||||||
|
downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Completed)
|
||||||
|
.count(),
|
||||||
|
"file"
|
||||||
|
)
|
||||||
|
)];
|
||||||
|
if !running.is_empty() {
|
||||||
|
parts.push(format!("{}/s", size(rate as u64)));
|
||||||
|
}
|
||||||
|
if left > 0 {
|
||||||
|
parts.push(format!("{} to go", size(left)));
|
||||||
|
if rate > 0.0 {
|
||||||
|
parts.push(format!("about {}", ui::format_time(left as f64 / rate)));
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
}
|
||||||
start = end;
|
ui.label(ui::muted(parts.join(" · ")));
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Pausing or cancelling keeps every block already downloaded.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One title's downloads, under a bar for all of them together.
|
||||||
|
fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
|
||||||
|
let title = &group[0].target.title_name;
|
||||||
|
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
|
||||||
|
let total: u64 = group.iter().map(|item| item.total).sum();
|
||||||
|
let completed = group
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Completed)
|
||||||
|
.count();
|
||||||
|
let totals_known = group.iter().all(|item| item.total > 0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Subhead.rich(title).strong());
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
ui.label(ui::muted(if totals_known {
|
||||||
|
format!(
|
||||||
|
"{completed} of {} · {} of {}",
|
||||||
|
group.len(),
|
||||||
|
size(downloaded),
|
||||||
|
size(total)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{completed} of {} · working out the size…", group.len())
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if totals_known && group.len() > 1 {
|
||||||
|
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
for item in group {
|
||||||
|
row(ui, item, actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
|
||||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -78,81 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
|||||||
item.target.subtitle.as_str()
|
item.target.subtitle.as_str()
|
||||||
};
|
};
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
[180.0, 18.0],
|
[72.0, 18.0],
|
||||||
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
|
egui::Label::new(egui::RichText::new(caption).monospace()).truncate(),
|
||||||
)
|
)
|
||||||
.on_hover_text(&item.target.name);
|
.on_hover_text(&item.target.name);
|
||||||
|
|
||||||
let status = match &item.state {
|
let (status, colour) = status(item);
|
||||||
DownloadState::Queued => "Queued".to_owned(),
|
|
||||||
DownloadState::Running => "Downloading".to_owned(),
|
|
||||||
DownloadState::Paused => "Paused".to_owned(),
|
|
||||||
DownloadState::Completed => "Available offline".to_owned(),
|
|
||||||
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
|
|
||||||
DownloadState::Failed(error) => format!("Failed: {error}"),
|
|
||||||
};
|
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
[240.0, 18.0],
|
[220.0, 18.0],
|
||||||
egui::Label::new(ui::muted(status)).truncate(),
|
egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if item.total > 0 {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.add(
|
buttons(ui, item, actions);
|
||||||
egui::ProgressBar::new(item.percent())
|
if item.total > 0 && item.state != DownloadState::Completed {
|
||||||
.desired_width(140.0)
|
ui.add_space(theme::space::M);
|
||||||
.show_percentage(),
|
ui.label(ui::muted(format!(
|
||||||
);
|
"{} / {}",
|
||||||
ui.label(ui::muted(format!(
|
size(item.downloaded),
|
||||||
"{} / {}",
|
size(item.total)
|
||||||
bytes(item.downloaded),
|
)));
|
||||||
bytes(item.total)
|
let width = (ui.available_width() - 8.0).clamp(40.0, 320.0);
|
||||||
)));
|
ui::progress_bar(ui, item.percent(), width);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
ui.with_layout(
|
|
||||||
egui::Layout::right_to_left(egui::Align::Center),
|
|
||||||
|ui| match item.state {
|
|
||||||
DownloadState::Queued | DownloadState::Running => {
|
|
||||||
if ui
|
|
||||||
.button("Cancel")
|
|
||||||
.on_hover_text("Stop and keep partial")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::CancelDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
if ui.button("Pause").clicked() {
|
|
||||||
actions.push(Action::PauseDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DownloadState::Paused => {
|
|
||||||
if ui
|
|
||||||
.button("Cancel")
|
|
||||||
.on_hover_text("Stop and keep partial")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::CancelDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
if ui.button("Resume").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
|
||||||
if ui.button("Delete partial…").clicked() {
|
|
||||||
actions.push(Action::RemoveDownload(item.key.clone(), true));
|
|
||||||
}
|
|
||||||
if ui.button("Resume / Retry").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DownloadState::Completed => {
|
|
||||||
if ui.button("Make online-only").clicked() {
|
|
||||||
actions.push(Action::RemoveDownload(item.key.clone(), false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a row says about itself, and in what colour.
|
||||||
|
fn status(item: &DownloadItem) -> (String, egui::Color32) {
|
||||||
|
match &item.state {
|
||||||
|
DownloadState::Queued => ("Waiting".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Running => {
|
||||||
|
let mut text = format!("{}/s", size(item.rate as u64));
|
||||||
|
if let Some(eta) = item.eta() {
|
||||||
|
text.push_str(&format!(" · {} left", ui::format_time(eta)));
|
||||||
|
}
|
||||||
|
(text, theme::text())
|
||||||
|
}
|
||||||
|
DownloadState::Paused => ("Paused".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Completed => ("Available offline".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Cancelled => ("Stopped · progress kept".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Failed(error) => (format!("Failed: {error}"), theme::danger()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||||
|
let key = || item.key.clone();
|
||||||
|
match item.state {
|
||||||
|
DownloadState::Queued | DownloadState::Running => {
|
||||||
|
if ui
|
||||||
|
.button("Cancel")
|
||||||
|
.on_hover_text("Stop, and keep what has downloaded")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::CancelDownload(key()));
|
||||||
|
}
|
||||||
|
if ui.button("Pause").clicked() {
|
||||||
|
actions.push(Action::PauseDownload(key()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Paused => {
|
||||||
|
if ui
|
||||||
|
.button("Cancel")
|
||||||
|
.on_hover_text("Stop, and keep what has downloaded")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::CancelDownload(key()));
|
||||||
|
}
|
||||||
|
if ui.button("Resume").clicked() {
|
||||||
|
actions.push(Action::ResumeDownload(key()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
||||||
|
if ui
|
||||||
|
.button("Delete")
|
||||||
|
.on_hover_text("Discard what has downloaded so far")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key(), true));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(if matches!(item.state, DownloadState::Failed(_)) {
|
||||||
|
"Retry"
|
||||||
|
} else {
|
||||||
|
"Resume"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ResumeDownload(key()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Completed => {
|
||||||
|
if ui
|
||||||
|
.button("Delete")
|
||||||
|
.on_hover_text("Delete the offline copy; keep the online source")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key(), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fraction(downloaded: u64, total: u64) -> f32 {
|
fn fraction(downloaded: u64, total: u64) -> f32 {
|
||||||
@@ -163,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bytes(value: u64) -> String {
|
fn size(bytes: u64) -> String {
|
||||||
const KIB: f64 = 1024.0;
|
ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX))
|
||||||
const MIB: f64 = 1024.0 * KIB;
|
|
||||||
const GIB: f64 = 1024.0 * MIB;
|
|
||||||
let value = value as f64;
|
|
||||||
if value >= GIB {
|
|
||||||
format!("{:.1} GiB", value / GIB)
|
|
||||||
} else if value >= MIB {
|
|
||||||
format!("{:.1} MiB", value / MIB)
|
|
||||||
} else if value >= KIB {
|
|
||||||
format!("{:.1} KiB", value / KIB)
|
|
||||||
} else {
|
|
||||||
format!("{value:.0} B")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,53 +2,87 @@
|
|||||||
|
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
|
|
||||||
use crate::app::{Action, Page};
|
use crate::app::{Action, Filter, LibraryView, Page, Sort};
|
||||||
|
use crate::playback::PlaybackTarget;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui::Art;
|
use crate::ui::Art;
|
||||||
use crate::ui::{self, Card};
|
use crate::ui::{self, Card};
|
||||||
|
|
||||||
pub fn show(
|
/// What the page lists, and what it was asked for.
|
||||||
ui: &mut egui::Ui,
|
pub struct Shelves<'a> {
|
||||||
art: &mut Art<'_>,
|
pub library: &'a Library,
|
||||||
library: &Library,
|
/// The search and the shelf, already worked out — see [`LibraryView`].
|
||||||
search: &str,
|
pub view: &'a LibraryView,
|
||||||
actions: &mut Vec<Action>,
|
/// Whether the catalog has been read yet.
|
||||||
) {
|
pub loaded: bool,
|
||||||
|
pub search: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
|
||||||
|
let Shelves {
|
||||||
|
library,
|
||||||
|
view,
|
||||||
|
loaded,
|
||||||
|
search,
|
||||||
|
} = shelves;
|
||||||
|
if !loaded {
|
||||||
|
return placeholder(ui);
|
||||||
|
}
|
||||||
if library.is_empty() {
|
if library.is_empty() {
|
||||||
return empty_state(ui, actions);
|
return empty_state(ui, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
let matches = library.search(search);
|
let pick = |indices: &[usize]| -> Vec<&Title> {
|
||||||
|
indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&index| library.titles.get(index))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let matches = pick(&view.matches);
|
||||||
let searching = !search.trim().is_empty();
|
let searching = !search.trim().is_empty();
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
if !searching {
|
if !searching {
|
||||||
let resumable = library.continue_watching();
|
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||||
|
hero(ui, art, title, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
|
let resumable = pick(&view.resumable);
|
||||||
if !resumable.is_empty() {
|
if !resumable.is_empty() {
|
||||||
ui::section(ui, "Continue watching");
|
ui::section(ui, "Continue watching");
|
||||||
continue_row(ui, art, &resumable, actions);
|
continue_row(ui, art, &resumable, actions);
|
||||||
ui.add_space(16.0);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::section(
|
ui.horizontal(|ui| {
|
||||||
ui,
|
ui::section(
|
||||||
&if searching {
|
ui,
|
||||||
format!(
|
&if searching {
|
||||||
"{} matching {:?}",
|
format!(
|
||||||
plural(matches.len(), "title"),
|
"{} matching {:?}",
|
||||||
search.trim()
|
plural(matches.len(), "title"),
|
||||||
)
|
search.trim()
|
||||||
} else {
|
)
|
||||||
plural(matches.len(), "title")
|
} else {
|
||||||
},
|
plural(matches.len(), "title")
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
shelf_controls(ui, view, actions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.label(ui::muted("Nothing here by that name."));
|
ui.label(ui::muted(if searching {
|
||||||
|
"Nothing here by that name."
|
||||||
|
} else {
|
||||||
|
"Nothing in the library fits that filter."
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +90,224 @@ pub fn show(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Days since the epoch: what the featured title turns over on.
|
||||||
|
fn today() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() / 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title the banner shows: whatever was watched last, so the top of the
|
||||||
|
/// page is one click from carrying on. With nothing part-watched, one of the
|
||||||
|
/// titles a provider has a backdrop for, turning over once a day — the same
|
||||||
|
/// one every time the page is opened today, rather than a new one per frame.
|
||||||
|
fn featured<'a>(
|
||||||
|
library: &'a Library,
|
||||||
|
view: &LibraryView,
|
||||||
|
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
|
||||||
|
day: u64,
|
||||||
|
) -> Option<&'a Title> {
|
||||||
|
if let Some(title) = view
|
||||||
|
.resumable
|
||||||
|
.first()
|
||||||
|
.and_then(|&index| library.titles.get(index))
|
||||||
|
{
|
||||||
|
return Some(title);
|
||||||
|
}
|
||||||
|
let pictured: Vec<&Title> = library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| {
|
||||||
|
metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.is_some_and(|found| found.backdrop_url.is_some())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if pictured.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pictured
|
||||||
|
.get((day % pictured.len() as u64) as usize)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The banner across the top of the library: one title, large, with what it
|
||||||
|
/// is and a way to play it.
|
||||||
|
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
||||||
|
let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(ui.available_width(), height),
|
||||||
|
egui::Sense::click(),
|
||||||
|
);
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
|
let picture = art.of(title);
|
||||||
|
let found = art
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.clone());
|
||||||
|
|
||||||
|
let painter = ui.painter().with_clip_rect(rect);
|
||||||
|
painter.rect_filled(rect, radius, theme::card());
|
||||||
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("art"),
|
||||||
|
picture.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
|
if art_in < 1.0 {
|
||||||
|
ui::placeholder(&painter, rect, "", 1.0 - art_in);
|
||||||
|
}
|
||||||
|
if let Some((texture, _)) = &picture {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Color32::WHITE.gamma_multiply(art_in),
|
||||||
|
)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The same two coats as the title page's band, for the same reason: a
|
||||||
|
// still is arbitrary, and the text over it has to read whatever it is.
|
||||||
|
if theme::ramps_on() {
|
||||||
|
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25));
|
||||||
|
let text_side = egui::Rect::from_min_max(
|
||||||
|
rect.min,
|
||||||
|
egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()),
|
||||||
|
);
|
||||||
|
painter.add(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
text_side,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Horizontal,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
let bottom =
|
||||||
|
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max);
|
||||||
|
painter.add(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
bottom,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Vertical,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = egui::Rect::from_min_max(
|
||||||
|
rect.min + egui::vec2(32.0, 0.0),
|
||||||
|
egui::pos2(
|
||||||
|
rect.left() + (rect.width() * 0.55).max(360.0),
|
||||||
|
rect.bottom() - 28.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ui.scope_builder(
|
||||||
|
egui::UiBuilder::new()
|
||||||
|
.max_rect(inner)
|
||||||
|
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|
||||||
|
|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if let Some(episode) = title.next_up() {
|
||||||
|
let label = match (title.resume(), episode.numbering()) {
|
||||||
|
(Some(_), Some(numbering)) => {
|
||||||
|
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
|
||||||
|
}
|
||||||
|
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
|
||||||
|
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
|
||||||
|
};
|
||||||
|
if ui::accent_button(ui, &label).clicked() {
|
||||||
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
if ui.button("More info").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
overview.to_owned(),
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
theme::text(),
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 3;
|
||||||
|
ui.label(job);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
}
|
||||||
|
let mut facts = vec![subtitle(title)];
|
||||||
|
if let Some(found) = &found {
|
||||||
|
if let Some(rating) = found.rating {
|
||||||
|
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
|
}
|
||||||
|
facts.extend(found.genres.iter().take(3).cloned());
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(facts.join(" · ")));
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Display
|
||||||
|
.rich(&title.name)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
)
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filter pills and the order, at the right of the grid's heading.
|
||||||
|
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
||||||
|
// Right to left: the order first, so it ends up last.
|
||||||
|
let sort = egui::ComboBox::from_id_salt("library-sort")
|
||||||
|
.selected_text(match view.sort {
|
||||||
|
Sort::Name => "A – Z",
|
||||||
|
Sort::Recent => "Recently watched",
|
||||||
|
Sort::Year => "Newest",
|
||||||
|
})
|
||||||
|
.width(150.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
let mut picked = None;
|
||||||
|
for (sort, label) in [
|
||||||
|
(Sort::Name, "A – Z"),
|
||||||
|
(Sort::Recent, "Recently watched"),
|
||||||
|
(Sort::Year, "Newest"),
|
||||||
|
] {
|
||||||
|
if ui.selectable_label(view.sort == sort, label).clicked() {
|
||||||
|
picked = Some(sort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picked
|
||||||
|
});
|
||||||
|
if let Some(Some(sort)) = sort.inner {
|
||||||
|
actions.push(Action::SetShelf(view.filter, sort));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if let Some(filter) = ui::widgets::segmented(
|
||||||
|
ui,
|
||||||
|
view.filter,
|
||||||
|
&[
|
||||||
|
(Filter::All, "All"),
|
||||||
|
(Filter::Series, "Series"),
|
||||||
|
(Filter::Films, "Films"),
|
||||||
|
(Filter::Watching, "Watching"),
|
||||||
|
(Filter::Unwatched, "Not started"),
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
actions.push(Action::SetShelf(filter, view.sort));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The top shelf: one card per part-watched title, scrolling sideways.
|
/// The top shelf: one card per part-watched title, scrolling sideways.
|
||||||
fn continue_row(
|
fn continue_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -85,7 +337,7 @@ fn continue_row(
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| episode.label());
|
.unwrap_or_else(|| episode.label());
|
||||||
|
|
||||||
let clicked = ui::card(
|
let response = ui::card(
|
||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: art.of(title),
|
art: art.of(title),
|
||||||
@@ -93,11 +345,14 @@ fn continue_row(
|
|||||||
subtitle: remaining,
|
subtitle: remaining,
|
||||||
progress: episode.progress().map(|value| value as f32),
|
progress: episode.progress().map(|value| value as f32),
|
||||||
badge: episode.numbering(),
|
badge: episode.numbering(),
|
||||||
|
// A shelf that scrolls sideways has no right edge
|
||||||
|
// to reach, so nothing to flex to.
|
||||||
|
width: theme::CARD_WIDTH,
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.clicked();
|
tile_menu(&response, title, true, actions);
|
||||||
|
|
||||||
if clicked {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,11 +362,31 @@ fn continue_row(
|
|||||||
|
|
||||||
/// Every title, wrapped to the window.
|
/// Every title, wrapped to the window.
|
||||||
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
||||||
let columns = ui::columns(ui.available_width());
|
let grid = ui::columns(ui.available_width());
|
||||||
for row in titles.chunks(columns) {
|
let row_height = ui::card_height(grid.width);
|
||||||
|
for row in titles.chunks(grid.columns) {
|
||||||
|
// A row scrolled out of view is only its height. Laying it out anyway
|
||||||
|
// costs a subtitle and an artwork lookup per card per frame, and while
|
||||||
|
// a film plays under the transport bar a frame is every frame.
|
||||||
|
let slot = egui::Rect::from_min_size(
|
||||||
|
ui.cursor().min,
|
||||||
|
egui::vec2(ui.available_width(), row_height),
|
||||||
|
);
|
||||||
|
if !ui.is_rect_visible(slot) {
|
||||||
|
// The spacing `horizontal` would have added after itself, too, or
|
||||||
|
// the page grows and shrinks under the scroll bar as rows come and
|
||||||
|
// go.
|
||||||
|
ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
// The gap the width was divided around, so what is drawn matches
|
||||||
|
// what was measured. egui's default item spacing is narrower, and
|
||||||
|
// the difference times the column count is a visible drift towards
|
||||||
|
// the left edge.
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
for title in row {
|
for title in row {
|
||||||
let clicked = ui::card(
|
let response = ui::card(
|
||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: art.of(title),
|
art: art.of(title),
|
||||||
@@ -121,11 +396,12 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
// What a film is says itself in the subtitle below the
|
// What a film is says itself in the subtitle below the
|
||||||
// card; a second `Film` over the poster is noise.
|
// card; a second `Film` over the poster is noise.
|
||||||
badge: None,
|
badge: None,
|
||||||
|
width: grid.width,
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.clicked();
|
tile_menu(&response, title, false, actions);
|
||||||
|
|
||||||
if clicked {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +410,80 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a right-click on a tile offers: the things otherwise one page away.
|
||||||
|
///
|
||||||
|
/// `continuing` is the Continue watching shelf, where the tile can also be
|
||||||
|
/// taken off the shelf.
|
||||||
|
fn tile_menu(
|
||||||
|
response: &egui::Response,
|
||||||
|
title: &Title,
|
||||||
|
continuing: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
response.context_menu(|ui| {
|
||||||
|
if let Some(episode) = title.next_up() {
|
||||||
|
let label = match (title.resume(), episode.numbering()) {
|
||||||
|
(Some(_), Some(numbering)) => format!("Resume {numbering}"),
|
||||||
|
(Some(_), None) => "Resume".to_owned(),
|
||||||
|
(None, _) => "Play".to_owned(),
|
||||||
|
};
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("Open").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let all_watched = title.watched_count() == title.episode_count();
|
||||||
|
if ui
|
||||||
|
.button(if all_watched {
|
||||||
|
"Mark unwatched"
|
||||||
|
} else {
|
||||||
|
"Mark watched"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetTitleWatched {
|
||||||
|
key: title.key.clone(),
|
||||||
|
watched: !all_watched,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if continuing
|
||||||
|
&& let Some(episode) = title.resume()
|
||||||
|
&& ui
|
||||||
|
.button("Remove from Continue watching")
|
||||||
|
.on_hover_text("Forget where you stopped in it")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ForgetPosition {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = title.episode_count();
|
||||||
|
let download = if count == 1 {
|
||||||
|
"Download".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("Download all {count}")
|
||||||
|
};
|
||||||
|
if ui.button(download).clicked() {
|
||||||
|
actions.push(Action::MakeOffline(
|
||||||
|
title
|
||||||
|
.episodes()
|
||||||
|
.map(|episode| PlaybackTarget::new(title, episode))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Change match…").clicked() {
|
||||||
|
actions.push(Action::OpenMatcher(title.key.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The grey line under a card: what it is, how much there is, and how much is
|
/// The grey line under a card: what it is, how much there is, and how much is
|
||||||
/// left.
|
/// left.
|
||||||
///
|
///
|
||||||
@@ -159,19 +509,50 @@ fn subtitle(title: &Title) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page before the catalog has been read: the shape of a grid, with
|
||||||
|
/// nothing in it yet.
|
||||||
|
///
|
||||||
|
/// Not the empty state. The catalog is on disk and is read in a fraction of a
|
||||||
|
/// second, and telling every launch "Nothing in the library yet" for that
|
||||||
|
/// fraction is a flash of something false.
|
||||||
|
fn placeholder(ui: &mut egui::Ui) {
|
||||||
|
let grid = ui::columns(ui.available_width());
|
||||||
|
let height = ui::card_height(grid.width);
|
||||||
|
ui::section(ui, "Library");
|
||||||
|
for _ in 0..2 {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
|
for _ in 0..grid.columns {
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover());
|
||||||
|
let image = egui::Rect::from_min_size(
|
||||||
|
rect.min,
|
||||||
|
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
|
||||||
|
);
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
image,
|
||||||
|
egui::CornerRadius::same(theme::radius::MD),
|
||||||
|
theme::card(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::CARD_GAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.add_space(120.0);
|
ui.add_space(120.0);
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("Nothing in the library yet")
|
theme::Role::Title
|
||||||
.size(20.0)
|
.rich("Nothing in the library yet")
|
||||||
.strong(),
|
.strong(),
|
||||||
);
|
);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||||
));
|
));
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
if ui::accent_button(ui, "Add a share").clicked() {
|
if ui::accent_button(ui, "Add a share").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::Goto(Page::Shares));
|
||||||
}
|
}
|
||||||
@@ -216,6 +597,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_banner_features_the_title_watched_last_and_nothing_without_a_picture() {
|
||||||
|
let mut second = title(TitleKind::Film, None);
|
||||||
|
second.key = "second".into();
|
||||||
|
let library = Library {
|
||||||
|
titles: vec![title(TitleKind::Film, None), second],
|
||||||
|
};
|
||||||
|
let metadata = std::collections::HashMap::new();
|
||||||
|
let mut view = LibraryView::default();
|
||||||
|
assert!(featured(&library, &view, &metadata, 0).is_none());
|
||||||
|
view.resumable = vec![1];
|
||||||
|
assert_eq!(
|
||||||
|
featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()),
|
||||||
|
Some("second")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plurals_read_naturally() {
|
fn plurals_read_naturally() {
|
||||||
assert_eq!(plural(1, "title"), "1 title");
|
assert_eq!(plural(1, "title"), "1 title");
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ pub fn show(
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::surface())
|
.fill(theme::surface())
|
||||||
.inner_margin(egui::Margin::same(18))
|
.inner_margin(egui::Margin::same(18))
|
||||||
.corner_radius(egui::CornerRadius::same(10)),
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
|
||||||
)
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(620.0);
|
ui.set_width(620.0);
|
||||||
|
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("Match “{}”", matcher.title_name))
|
theme::Role::Heading
|
||||||
.size(18.0)
|
.rich(format!("Match “{}”", matcher.title_name))
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
@@ -58,21 +58,21 @@ pub fn show(
|
|||||||
"Everything {} answers with, in its own order — nothing is scored here.",
|
"Everything {} answers with, in its own order — nothing is scored here.",
|
||||||
provider.label()
|
provider.label()
|
||||||
)));
|
)));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
search_row(ui, matcher, actions);
|
search_row(ui, matcher, actions);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
current_row(ui, matcher, current.as_ref(), actions);
|
current_row(ui, matcher, current.as_ref(), actions);
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
if let Some(error) = &matcher.error {
|
if let Some(error) = &matcher.error {
|
||||||
ui.label(egui::RichText::new(error).size(12.0).color(theme::danger()));
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
results(ui, matcher, art, actions);
|
results(ui, matcher, art, actions);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if ui.button("Close").clicked() {
|
if ui.button("Close").clicked() {
|
||||||
@@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
|
|||||||
if row(ui, art, found).clicked() {
|
if row(ui, art, found).clicked() {
|
||||||
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
|
|||||||
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
||||||
let response = egui::Frame::new()
|
let response = egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
|
||||||
.inner_margin(egui::Margin::same(8))
|
.inner_margin(egui::Margin::same(8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
@@ -204,16 +204,16 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
} else {
|
} else {
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::CornerRadius::same(4),
|
egui::CornerRadius::same(theme::radius::BADGE),
|
||||||
theme::card_hover(),
|
theme::card_hover(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(&found.name)
|
theme::Role::Body
|
||||||
.size(14.0)
|
.rich(&found.name)
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
@@ -224,7 +224,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
if let Some(overview) = &found.overview {
|
if let Some(overview) = &found.overview {
|
||||||
let mut job = egui::text::LayoutJob::simple(
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
overview.replace('\n', " "),
|
overview.replace('\n', " "),
|
||||||
egui::FontId::proportional(11.5),
|
theme::Role::Caption.font(),
|
||||||
theme::muted(),
|
theme::muted(),
|
||||||
ui.available_width(),
|
ui.available_width(),
|
||||||
);
|
);
|
||||||
@@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
response.rect,
|
response.rect,
|
||||||
egui::CornerRadius::same(6),
|
egui::CornerRadius::same(theme::radius::SM),
|
||||||
egui::Stroke::new(1.5, theme::accent()),
|
egui::Stroke::new(1.5, theme::accent()),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
@@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
|
|||||||
.or_else(|| found.backdrop_url.clone())?;
|
.or_else(|| found.backdrop_url.clone())?;
|
||||||
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
|
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
|
||||||
let engine = art.engine;
|
let engine = art.engine;
|
||||||
let requested = key.clone();
|
|
||||||
art.posters
|
art.posters
|
||||||
.texture(key, || engine.request_poster(requested, url))
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
||||||
@@ -280,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
|
|||||||
parts.push(ui::library::plural(episodes as usize, "episode"));
|
parts.push(ui::library::plural(episodes as usize, "episode"));
|
||||||
}
|
}
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("★ {rating:.1}"));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
+494
-88
@@ -4,12 +4,17 @@
|
|||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
pub mod history;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod settings;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
|
pub mod shortcuts;
|
||||||
pub mod title;
|
pub mod title;
|
||||||
|
pub mod toast;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -47,6 +52,28 @@ impl Art<'_> {
|
|||||||
.get(episode.node.parsed.season, number)
|
.get(episode.node.parsed.season, number)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A still for one episode: the provider's, else Proton's own thumbnail of
|
||||||
|
/// the file. Only asked for by rows that are on screen.
|
||||||
|
pub fn still(&mut self, title_key: &str, episode: &Episode) -> Option<egui::TextureHandle> {
|
||||||
|
let url = self
|
||||||
|
.episode(title_key, episode)
|
||||||
|
.and_then(|found| found.still_url.clone());
|
||||||
|
if let Some(url) = url {
|
||||||
|
let key = format!("still:{}", url);
|
||||||
|
let engine = self.engine;
|
||||||
|
if let Some(texture) = self
|
||||||
|
.posters
|
||||||
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
|
{
|
||||||
|
return Some(texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let node = &episode.node;
|
||||||
|
let engine = self.engine;
|
||||||
|
self.thumbs
|
||||||
|
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
||||||
|
}
|
||||||
|
|
||||||
/// The picture for a title, and how to fit it.
|
/// The picture for a title, and how to fit it.
|
||||||
///
|
///
|
||||||
/// Provider artwork first, then Proton's still, then nothing. That order is
|
/// Provider artwork first, then Proton's still, then nothing. That order is
|
||||||
@@ -61,12 +88,9 @@ impl Art<'_> {
|
|||||||
.and_then(|metadata| metadata.tile_art())
|
.and_then(|metadata| metadata.tile_art())
|
||||||
{
|
{
|
||||||
let engine = self.engine;
|
let engine = self.engine;
|
||||||
let key = title.key.clone();
|
if let Some(texture) = self.posters.texture(&title.key, || {
|
||||||
let url = url.to_string();
|
engine.request_poster(title.key.clone(), url.to_string())
|
||||||
if let Some(texture) = self
|
}) {
|
||||||
.posters
|
|
||||||
.texture(title.key.clone(), || engine.request_poster(key, url))
|
|
||||||
{
|
|
||||||
return Some((texture, shape));
|
return Some((texture, shape));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +99,7 @@ impl Art<'_> {
|
|||||||
let engine = self.engine;
|
let engine = self.engine;
|
||||||
let texture = self
|
let texture = self
|
||||||
.thumbs
|
.thumbs
|
||||||
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
|
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))?;
|
||||||
Some((texture, ArtShape::Landscape))
|
Some((texture, ArtShape::Landscape))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +118,12 @@ pub fn format_time(seconds: f64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `1.25×`, or `1×`.
|
||||||
|
pub fn format_speed(speed: f64) -> String {
|
||||||
|
let text = format!("{speed:.2}");
|
||||||
|
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
|
||||||
|
}
|
||||||
|
|
||||||
/// `1.4 GiB`, for what a file costs to watch.
|
/// `1.4 GiB`, for what a file costs to watch.
|
||||||
pub fn format_size(bytes: i64) -> String {
|
pub fn format_size(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||||
@@ -112,30 +142,273 @@ pub fn format_size(bytes: i64) -> String {
|
|||||||
|
|
||||||
/// A section title with a rule under it.
|
/// A section title with a rule under it.
|
||||||
pub fn section(ui: &mut egui::Ui, text: &str) {
|
pub fn section(ui: &mut egui::Ui, text: &str) {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(text)
|
theme::Role::Section
|
||||||
.size(17.0)
|
.rich(text)
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
ui.add_space(2.0);
|
ui.add_space(theme::space::XXS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Small grey text, for everything that is context rather than content.
|
/// Small grey text, for everything that is context rather than content.
|
||||||
pub fn muted(text: impl Into<String>) -> egui::RichText {
|
pub fn muted(text: impl Into<String>) -> egui::RichText {
|
||||||
egui::RichText::new(text).size(12.0).color(theme::muted())
|
theme::Role::Caption.rich(text).color(theme::muted())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one button style that means "this is the action".
|
/// The one button style that means "this is the action".
|
||||||
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
filled(ui, text, true, Vec2::new(0.0, 0.0))
|
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tab in the top bar. The selected one wears the accent; the others are text
|
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
|
||||||
/// until the pointer is over them.
|
///
|
||||||
pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
||||||
filled(ui, text, selected, Vec2::new(4.0, 0.0))
|
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
|
||||||
|
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||||
|
let id = egui::Id::new("library-search");
|
||||||
|
let width = 240.0;
|
||||||
|
let height = ui.spacing().interact_size.y;
|
||||||
|
let (outer, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
|
||||||
|
let has_focus = ui.memory(|memory| memory.has_focus(id));
|
||||||
|
let lit = ui
|
||||||
|
.ctx()
|
||||||
|
.animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER);
|
||||||
|
|
||||||
|
let radius = CornerRadius::same((height / 2.0) as u8);
|
||||||
|
ui.painter().rect_filled(outer, radius, theme::card());
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
outer,
|
||||||
|
radius,
|
||||||
|
Stroke::new(1.0, theme::card_hover().lerp_to_gamma(theme::accent(), lit)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A magnifier: a ring and a handle, so no font has to have the glyph.
|
||||||
|
let ink = theme::muted().lerp_to_gamma(theme::text(), lit);
|
||||||
|
let lens = egui::pos2(outer.left() + 16.0, outer.center().y - 1.0);
|
||||||
|
ui.painter().circle_stroke(lens, 5.0, Stroke::new(1.5, ink));
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[lens + Vec2::new(3.6, 3.6), lens + Vec2::new(7.0, 7.0)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
|
||||||
|
let clear_width = if search.is_empty() { 0.0 } else { 26.0 };
|
||||||
|
let field_rect = Rect::from_min_max(
|
||||||
|
egui::pos2(outer.left() + 28.0, outer.top()),
|
||||||
|
egui::pos2(outer.right() - 8.0 - clear_width, outer.bottom()),
|
||||||
|
);
|
||||||
|
let response = ui.put(
|
||||||
|
field_rect,
|
||||||
|
egui::TextEdit::singleline(search)
|
||||||
|
.id(id)
|
||||||
|
.frame(egui::Frame::NONE)
|
||||||
|
.hint_text(if has_focus {
|
||||||
|
"Titles and filenames"
|
||||||
|
} else {
|
||||||
|
"Search Ctrl+F"
|
||||||
|
})
|
||||||
|
.vertical_align(egui::Align::Center)
|
||||||
|
.desired_width(field_rect.width()),
|
||||||
|
);
|
||||||
|
if focus {
|
||||||
|
response.request_focus();
|
||||||
|
}
|
||||||
|
if response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||||
|
search.clear();
|
||||||
|
response.surrender_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !search.is_empty() {
|
||||||
|
let clear = Rect::from_center_size(
|
||||||
|
egui::pos2(outer.right() - 18.0, outer.center().y),
|
||||||
|
Vec2::splat(18.0),
|
||||||
|
);
|
||||||
|
let response = ui.interact(clear, id.with("clear"), Sense::click());
|
||||||
|
let ink = if response.hovered() {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
};
|
||||||
|
let c = clear.center();
|
||||||
|
let d = 3.5;
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
if response
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Clear (Esc)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
search.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thin bar in the accent, for how far something has got.
|
||||||
|
///
|
||||||
|
/// egui's own progress bar is one flat colour with the percentage written over
|
||||||
|
/// it; this is the same bar the tiles and the seek bar use, so a download reads
|
||||||
|
/// as part of the same app.
|
||||||
|
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
|
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
||||||
|
let mut done = rect;
|
||||||
|
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
|
||||||
|
if done.width() > 0.5 {
|
||||||
|
theme::accent_fill(ui.painter(), done, radius, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A centred message for a page with nothing on it: what is missing, and what
|
||||||
|
/// to do about it.
|
||||||
|
pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(96.0);
|
||||||
|
ui.label(theme::Role::Title.rich(heading).strong());
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(muted(body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The danger colour as a button: for the one choice in a dialog that cannot be
|
||||||
|
/// taken back.
|
||||||
|
pub fn danger_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
ui.add(
|
||||||
|
egui::Button::new(egui::RichText::new(text).color(theme::on_accent()))
|
||||||
|
.fill(theme::danger()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a viewer answered a [`confirm`] dialog with.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Answer {
|
||||||
|
/// Still open.
|
||||||
|
Pending,
|
||||||
|
Confirmed,
|
||||||
|
/// Cancelled — by the button, Escape, or a click beside the dialog.
|
||||||
|
Declined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A question with two answers, over everything else.
|
||||||
|
///
|
||||||
|
/// For the actions that destroy something the viewer cannot get back from
|
||||||
|
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
||||||
|
/// the click.
|
||||||
|
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
||||||
|
let mut answer = Answer::Pending;
|
||||||
|
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.inner_margin(egui::Margin::same(18))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG)),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(420.0);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Heading
|
||||||
|
.rich(heading)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if danger_button(ui, verb).clicked() {
|
||||||
|
answer = Answer::Confirmed;
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
answer = Answer::Declined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if answer == Answer::Pending && modal.should_close() {
|
||||||
|
answer = Answer::Declined;
|
||||||
|
}
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`filled`] paints behind its label.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Fill {
|
||||||
|
/// The accent, gradient and all.
|
||||||
|
Accent,
|
||||||
|
/// Nothing — something else already painted the accent here, and the label
|
||||||
|
/// only has to be legible on it.
|
||||||
|
Ink,
|
||||||
|
/// Nothing at rest, the hover colour under the pointer.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of tabs with one accent pill that slides between them.
|
||||||
|
///
|
||||||
|
/// Drawn as a row rather than a tab at a time because the pill has to be
|
||||||
|
/// painted *behind* labels whose positions are not known until they have been
|
||||||
|
/// laid out, and because a pill that moves has to be one pill: a per-tab fill
|
||||||
|
/// can only ever cross-fade, which reads as two things blinking rather than one
|
||||||
|
/// thing travelling.
|
||||||
|
///
|
||||||
|
/// Returns the index that was clicked, if any.
|
||||||
|
pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<usize> {
|
||||||
|
// Reserved now, filled in below: everything drawn after this lands on top
|
||||||
|
// of it whatever it turns out to be.
|
||||||
|
let pill = ui.painter().add(egui::Shape::Noop);
|
||||||
|
|
||||||
|
let mut clicked = None;
|
||||||
|
let mut selected = None;
|
||||||
|
for (index, (text, is_selected)) in items.iter().enumerate() {
|
||||||
|
let response = tab(ui, *is_selected, text);
|
||||||
|
if *is_selected {
|
||||||
|
selected = Some(response.rect);
|
||||||
|
}
|
||||||
|
if response.clicked() {
|
||||||
|
clicked = Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(target) = selected {
|
||||||
|
// Interpolating the edges rather than the centre and the width keeps
|
||||||
|
// the pill from overshooting when it moves between tabs of different
|
||||||
|
// lengths — both edges arrive at the same time.
|
||||||
|
let ctx = ui.ctx();
|
||||||
|
let left =
|
||||||
|
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
|
||||||
|
let right =
|
||||||
|
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
|
||||||
|
let rect = Rect::from_min_max(
|
||||||
|
egui::pos2(left, target.top()),
|
||||||
|
egui::pos2(right, target.bottom()),
|
||||||
|
);
|
||||||
|
ui.painter().set(
|
||||||
|
pill,
|
||||||
|
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tab. The selected one is ink on the pill [`tabs`] draws; the others are
|
||||||
|
/// text until the pointer is over them.
|
||||||
|
fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
||||||
|
filled(
|
||||||
|
ui,
|
||||||
|
text,
|
||||||
|
if selected { Fill::Ink } else { Fill::None },
|
||||||
|
Vec2::new(4.0, 0.0),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A button painted by hand, so that "filled with the accent" can mean a
|
/// A button painted by hand, so that "filled with the accent" can mean a
|
||||||
@@ -144,7 +417,7 @@ pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
|||||||
/// `egui::Button` takes a single `Color32` and there is no way in to give it
|
/// `egui::Button` takes a single `Color32` and there is no way in to give it
|
||||||
/// anything else, so the fill, the label and the hover states are all drawn
|
/// anything else, so the fill, the label and the hover states are all drawn
|
||||||
/// here. Everything else — sizing, padding, the click — is still egui's.
|
/// here. Everything else — sizing, padding, the click — is still egui's.
|
||||||
fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::Response {
|
fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Response {
|
||||||
let padding = ui.spacing().button_padding + extra;
|
let padding = ui.spacing().button_padding + extra;
|
||||||
let galley = ui.painter().layout_no_wrap(
|
let galley = ui.painter().layout_no_wrap(
|
||||||
text.to_owned(),
|
text.to_owned(),
|
||||||
@@ -161,24 +434,40 @@ fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::R
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
let lift = if response.is_pointer_button_down_on() {
|
// Eased rather than switched, so the veil arrives over a few frames instead
|
||||||
-0.14
|
// of appearing whole the moment the pointer crosses the edge. The press is
|
||||||
} else if response.hovered() {
|
// quicker than the hover: a click should feel like it registered, not like
|
||||||
0.10
|
// it was considered.
|
||||||
} else {
|
let ctx = ui.ctx().clone();
|
||||||
0.0
|
let hover = ctx.animate_bool_with_time(
|
||||||
};
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
let press = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("press"),
|
||||||
|
response.is_pointer_button_down_on(),
|
||||||
|
theme::motion::PRESS,
|
||||||
|
);
|
||||||
|
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
|
||||||
|
|
||||||
let ink = if accented {
|
let ink = match fill {
|
||||||
theme::accent_fill(ui.painter(), rect, radius, lift);
|
Fill::Accent => {
|
||||||
theme::on_accent()
|
theme::accent_fill(ui.painter(), rect, radius, lift);
|
||||||
} else {
|
theme::on_accent()
|
||||||
if response.hovered() {
|
}
|
||||||
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
// The pill is already under this one; only the veil is missing.
|
||||||
theme::text()
|
Fill::Ink => {
|
||||||
} else {
|
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||||
theme::muted()
|
theme::on_accent()
|
||||||
|
}
|
||||||
|
Fill::None => {
|
||||||
|
if hover > 0.0 {
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, radius, theme::card_hover().gamma_multiply(hover));
|
||||||
|
}
|
||||||
|
theme::muted().lerp_to_gamma(theme::text(), hover)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,6 +486,23 @@ pub struct Card<'a> {
|
|||||||
pub progress: Option<f32>,
|
pub progress: Option<f32>,
|
||||||
/// A short label in the corner — `S01E04`.
|
/// A short label in the corner — `S01E04`.
|
||||||
pub badge: Option<String>,
|
pub badge: Option<String>,
|
||||||
|
/// How wide to draw it.
|
||||||
|
///
|
||||||
|
/// A grid flexes this so its rows reach the right edge of the window; a
|
||||||
|
/// sideways-scrolling shelf has no edge to reach and passes
|
||||||
|
/// [`theme::CARD_WIDTH`].
|
||||||
|
pub width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
|
||||||
|
/// before the name is laid out — and because a grid whose rows are each a
|
||||||
|
/// different height by title length reads as broken.
|
||||||
|
const CARD_TEXT_HEIGHT: f32 = 54.0;
|
||||||
|
|
||||||
|
/// How tall [`card`] draws a tile of this width, so a grid can step over rows
|
||||||
|
/// it does not draw.
|
||||||
|
pub fn card_height(width: f32) -> f32 {
|
||||||
|
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one tile and report whether it was clicked.
|
/// Draw one tile and report whether it was clicked.
|
||||||
@@ -208,31 +514,63 @@ pub struct Card<'a> {
|
|||||||
/// the title, usually. The gap it leaves is filled with the card colour, which
|
/// the title, usually. The gap it leaves is filled with the card colour, which
|
||||||
/// reads as deliberate in a way a stretched poster does not.
|
/// reads as deliberate in a way a stretched poster does not.
|
||||||
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||||
let width = theme::CARD_WIDTH;
|
let width = card.width;
|
||||||
let image_height = (width * theme::CARD_ASPECT).round();
|
let image_height = (width * theme::CARD_ASPECT).round();
|
||||||
// Two lines of name plus one of subtitle. Fixed, because the tile is
|
let text_height = CARD_TEXT_HEIGHT;
|
||||||
// allocated before the name is laid out — and because a grid whose rows are
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
// each a different height by title length reads as broken.
|
|
||||||
let text_height = 54.0;
|
|
||||||
let radius = CornerRadius::same(8);
|
|
||||||
|
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||||
|
// Reached with the arrow keys or Tab: bring it into view, before the
|
||||||
|
// early return below would skip a tile that is just off screen.
|
||||||
|
if response.gained_focus() {
|
||||||
|
response.scroll_to_me(None);
|
||||||
|
}
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||||
let hovered = response.hovered();
|
// Hover is a value, not a flag: the border, the lift and the shadow all
|
||||||
|
// ride it, so they arrive together rather than each snapping on its own.
|
||||||
|
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||||
|
// the frame rate — and it needs no state kept here.
|
||||||
|
// Focus lights a tile the way the pointer does, so the keyboard can be
|
||||||
|
// followed across the grid.
|
||||||
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered() || response.has_focus(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
// The picture arrives whenever its download finishes, several frames after
|
||||||
|
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||||
|
// letters popping into stills into one settle.
|
||||||
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("art"),
|
||||||
|
card.art.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
|
if hover > 0.0 {
|
||||||
|
painter.add(theme::tile_shadow(hover).as_shape(image_rect, radius));
|
||||||
|
}
|
||||||
painter.rect_filled(image_rect, radius, theme::card());
|
painter.rect_filled(image_rect, radius, theme::card());
|
||||||
|
|
||||||
|
// Under the picture, not instead of it: it is what shows through while the
|
||||||
|
// art fades in, and it is the whole tile when there is no art to come.
|
||||||
|
if art_in < 1.0 {
|
||||||
|
placeholder(painter, image_rect, card.name, 1.0 - art_in);
|
||||||
|
}
|
||||||
|
|
||||||
|
// White because a textured rect *multiplies* its fill by the texture; the
|
||||||
|
// alpha on it is what fades the picture in.
|
||||||
|
let ink = Color32::WHITE.gamma_multiply(art_in);
|
||||||
match &card.art {
|
match &card.art {
|
||||||
Some((texture, ArtShape::Landscape)) => {
|
Some((texture, ArtShape::Landscape)) => {
|
||||||
let size = texture.size_vec2();
|
let size = texture.size_vec2();
|
||||||
painter.add(
|
painter.add(
|
||||||
egui::epaint::RectShape::filled(image_rect, radius, Color32::WHITE)
|
egui::epaint::RectShape::filled(image_rect, radius, ink)
|
||||||
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
|
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -241,7 +579,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
egui::epaint::RectShape::filled(
|
egui::epaint::RectShape::filled(
|
||||||
contain_rect(texture.size_vec2(), image_rect),
|
contain_rect(texture.size_vec2(), image_rect),
|
||||||
radius,
|
radius,
|
||||||
Color32::WHITE,
|
ink,
|
||||||
)
|
)
|
||||||
.with_texture(
|
.with_texture(
|
||||||
texture.id(),
|
texture.id(),
|
||||||
@@ -249,31 +587,20 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {}
|
||||||
painter.text(
|
|
||||||
image_rect.center(),
|
|
||||||
Align2::CENTER_CENTER,
|
|
||||||
initials(card.name),
|
|
||||||
egui::FontId::proportional(28.0),
|
|
||||||
theme::muted(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(badge) = &card.badge {
|
if let Some(badge) = &card.badge {
|
||||||
let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0);
|
let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0);
|
||||||
let galley = painter.layout_no_wrap(
|
let galley =
|
||||||
badge.clone(),
|
painter.layout_no_wrap(badge.clone(), theme::Role::Micro.font(), Color32::WHITE);
|
||||||
egui::FontId::proportional(11.0),
|
|
||||||
Color32::WHITE,
|
|
||||||
);
|
|
||||||
let background = Rect::from_min_size(
|
let background = Rect::from_min_size(
|
||||||
anchor - Vec2::new(galley.size().x + 10.0, 0.0),
|
anchor - Vec2::new(galley.size().x + 10.0, 0.0),
|
||||||
galley.size() + Vec2::new(10.0, 4.0),
|
galley.size() + Vec2::new(10.0, 4.0),
|
||||||
);
|
);
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
background,
|
background,
|
||||||
CornerRadius::same(4),
|
CornerRadius::same(theme::radius::BADGE),
|
||||||
Color32::from_black_alpha(180),
|
Color32::from_black_alpha(180),
|
||||||
);
|
);
|
||||||
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
|
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
|
||||||
@@ -314,11 +641,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
theme::accent_fill(&clipped_painter, played, played_radius, 0.0);
|
theme::accent_fill(&clipped_painter, played, played_radius, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if hovered {
|
if hover > 0.0 {
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
image_rect,
|
image_rect,
|
||||||
radius,
|
radius,
|
||||||
Stroke::new(2.0, theme::accent()),
|
Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -333,12 +660,10 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
// the line below it.
|
// the line below it.
|
||||||
let mut job = egui::text::LayoutJob::simple(
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
card.name.to_string(),
|
card.name.to_string(),
|
||||||
egui::FontId::proportional(14.0),
|
theme::Role::Body.font(),
|
||||||
if hovered {
|
// Towards white as the pointer arrives, so the name lifts with the
|
||||||
Color32::WHITE
|
// border rather than staying put while everything around it moves.
|
||||||
} else {
|
theme::text().lerp_to_gamma(Color32::WHITE, hover),
|
||||||
theme::text()
|
|
||||||
},
|
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
job.wrap.max_rows = 2;
|
job.wrap.max_rows = 2;
|
||||||
@@ -350,7 +675,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
text_rect.min + Vec2::new(0.0, name_height + 2.0),
|
text_rect.min + Vec2::new(0.0, name_height + 2.0),
|
||||||
Align2::LEFT_TOP,
|
Align2::LEFT_TOP,
|
||||||
card.subtitle,
|
card.subtitle,
|
||||||
egui::FontId::proportional(11.5),
|
theme::Role::Caption.font(),
|
||||||
theme::muted(),
|
theme::muted(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -371,7 +696,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// UV rectangle that crops `image` to fill `target` without distorting it.
|
/// UV rectangle that crops `image` to fill `target` without distorting it.
|
||||||
fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
pub fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
||||||
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
|
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
|
||||||
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
||||||
}
|
}
|
||||||
@@ -386,20 +711,84 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
|||||||
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
|
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Up to two letters, for a tile with no picture.
|
/// What a tile with no picture shows: a gradient of its own, and its name.
|
||||||
fn initials(name: &str) -> String {
|
///
|
||||||
name.split_whitespace()
|
/// Two grey letters on a grey card was the whole tile for any title nothing
|
||||||
.filter_map(|word| word.chars().next())
|
/// knew a picture for — a wall of them read as a page that had failed to load.
|
||||||
.filter(|character| character.is_alphanumeric())
|
/// The colours come from the name, so a title wears the same ones every time
|
||||||
.take(2)
|
/// and two titles side by side rarely match.
|
||||||
.flat_map(char::to_uppercase)
|
///
|
||||||
.collect()
|
/// The hash is FNV-1a: stable across runs and platforms, which the standard
|
||||||
|
/// library's hasher deliberately is not.
|
||||||
|
pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opacity: f32) {
|
||||||
|
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
|
||||||
|
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
|
||||||
|
});
|
||||||
|
let hue = (hash % 360) as f32 / 360.0;
|
||||||
|
let light = theme::palette().light;
|
||||||
|
let (saturation, value) = if light { (0.25, 0.92) } else { (0.45, 0.34) };
|
||||||
|
let from: Color32 = egui::ecolor::Hsva::new(hue, saturation, value, 1.0).into();
|
||||||
|
let to: Color32 =
|
||||||
|
egui::ecolor::Hsva::new((hue + 0.12) % 1.0, saturation, value * 0.65, 1.0).into();
|
||||||
|
theme::ramp_fill(
|
||||||
|
painter,
|
||||||
|
rect,
|
||||||
|
CornerRadius::same(theme::radius::MD),
|
||||||
|
(from, to),
|
||||||
|
theme::Direction::Vertical,
|
||||||
|
opacity,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ink = if light {
|
||||||
|
Color32::from_black_alpha(170)
|
||||||
|
} else {
|
||||||
|
Color32::from_white_alpha(215)
|
||||||
|
}
|
||||||
|
.gamma_multiply(opacity);
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
name.to_owned(),
|
||||||
|
theme::Role::Heading.font(),
|
||||||
|
ink,
|
||||||
|
rect.width() - 28.0,
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 3;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
job.halign = egui::Align::Center;
|
||||||
|
let galley = painter.layout_job(job);
|
||||||
|
painter.galley(
|
||||||
|
egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0),
|
||||||
|
galley,
|
||||||
|
ink,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many cards fit, and the gap that leaves them evenly spread.
|
/// How a grid divides the width it was given.
|
||||||
pub fn columns(available: f32) -> usize {
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
(((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() as usize)
|
pub struct Grid {
|
||||||
.max(1)
|
pub columns: usize,
|
||||||
|
/// What each card should be drawn at, so the row reaches both edges.
|
||||||
|
pub width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fit as many cards as will go, then widen them to use the rest.
|
||||||
|
///
|
||||||
|
/// [`theme::CARD_WIDTH`] is a *minimum*, not the width. Dividing by it and
|
||||||
|
/// flooring — which is what this used to do — leaves up to one whole card of
|
||||||
|
/// dead space at the right of every row, and on a wide window that reads as a
|
||||||
|
/// layout that failed to line up rather than as a margin.
|
||||||
|
pub fn columns(available: f32) -> Grid {
|
||||||
|
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor()
|
||||||
|
as usize)
|
||||||
|
.max(1);
|
||||||
|
// The gaps come out of the width before it is split. Floored, because a
|
||||||
|
// fraction of a point times four columns is enough to push the last card
|
||||||
|
// onto a row of its own. A window too narrow for even one full card gets a
|
||||||
|
// card narrower than the minimum, which is better than one clipped by the
|
||||||
|
// edge.
|
||||||
|
let width = ((available - theme::CARD_GAP * (columns - 1) as f32) / columns as f32)
|
||||||
|
.floor()
|
||||||
|
.max(1.0);
|
||||||
|
Grid { columns, width }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -446,15 +835,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initials_take_two_letters() {
|
fn column_count_never_reaches_zero() {
|
||||||
assert_eq!(initials("Cowboy Bebop"), "CB");
|
assert_eq!(columns(0.0).columns, 1);
|
||||||
assert_eq!(initials("Akira"), "A");
|
assert!(columns(2000.0).columns > 1);
|
||||||
assert_eq!(initials(""), "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn column_count_never_reaches_zero() {
|
fn a_row_of_cards_fills_the_width_it_was_given() {
|
||||||
assert_eq!(columns(0.0), 1);
|
// The defect this replaced: floor(available / CARD_WIDTH) cards at a
|
||||||
assert!(columns(2000.0) > 1);
|
// fixed width leave up to one card's worth of the window empty.
|
||||||
|
for available in [400.0, 813.0, 1280.0, 1920.5, 3440.0] {
|
||||||
|
let grid = columns(available);
|
||||||
|
let used =
|
||||||
|
grid.width * grid.columns as f32 + theme::CARD_GAP * (grid.columns - 1) as f32;
|
||||||
|
assert!(used <= available, "{available}: {used} overflows the row");
|
||||||
|
assert!(
|
||||||
|
available - used < grid.columns as f32 + 1.0,
|
||||||
|
"{available}: {} left over",
|
||||||
|
available - used,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cards_never_go_below_the_minimum_while_they_still_fit() {
|
||||||
|
for available in [500.0, 900.0, 2400.0] {
|
||||||
|
assert!(columns(available).width >= theme::CARD_WIDTH);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+516
-104
@@ -66,6 +66,10 @@ const SKIP_PAD_X: f32 = 22.0;
|
|||||||
/// on the full wash. See [`scrim_bottom`].
|
/// on the full wash. See [`scrim_bottom`].
|
||||||
const SCRIM_ALPHA: f32 = 170.0;
|
const SCRIM_ALPHA: f32 = 170.0;
|
||||||
const SCRIM_FADE: f32 = 88.0;
|
const SCRIM_FADE: f32 = 88.0;
|
||||||
|
/// How wide the episode list is, at most. A narrow window gives it less.
|
||||||
|
const DRAWER_WIDTH: f32 = 380.0;
|
||||||
|
/// The stills in the episode list.
|
||||||
|
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
|
||||||
|
|
||||||
/// State the page keeps between frames.
|
/// State the page keeps between frames.
|
||||||
///
|
///
|
||||||
@@ -109,6 +113,17 @@ pub struct Chrome<'a> {
|
|||||||
pub up_next: Option<UpNextCard>,
|
pub up_next: Option<UpNextCard>,
|
||||||
/// Whether stepping to another episode is possible in either direction.
|
/// Whether stepping to another episode is possible in either direction.
|
||||||
pub neighbours: Neighbours,
|
pub neighbours: Neighbours,
|
||||||
|
/// The title's episodes, when it has more than one — what the list down
|
||||||
|
/// the right of the picture shows.
|
||||||
|
pub episodes: Option<Episodes<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The playing title's episodes, for the list beside the picture.
|
||||||
|
pub struct Episodes<'a> {
|
||||||
|
pub title: &'a pstr_core::library::Title,
|
||||||
|
pub art: ui::Art<'a>,
|
||||||
|
/// Whether the list is out.
|
||||||
|
pub open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the end-of-episode card says, when there is one.
|
/// What the end-of-episode card says, when there is one.
|
||||||
@@ -118,11 +133,21 @@ pub struct Chrome<'a> {
|
|||||||
pub struct UpNextCard {
|
pub struct UpNextCard {
|
||||||
/// Seconds until the next episode starts.
|
/// Seconds until the next episode starts.
|
||||||
pub seconds: f64,
|
pub seconds: f64,
|
||||||
|
/// The same, as a fraction of the whole countdown, for the ring.
|
||||||
|
pub left: f32,
|
||||||
/// Which episode that is.
|
/// Which episode that is.
|
||||||
pub caption: String,
|
pub caption: String,
|
||||||
|
/// Its still, once there is one.
|
||||||
|
pub still: Option<egui::TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
|
/// How much of the bottom of the window the controls take, the last time
|
||||||
|
/// they were drawn — so things floating over the picture can keep clear.
|
||||||
|
pub fn covered(&self) -> f32 {
|
||||||
|
self.chrome_height
|
||||||
|
}
|
||||||
|
|
||||||
/// Fold this frame's pointer into the overlay's timer.
|
/// Fold this frame's pointer into the overlay's timer.
|
||||||
pub fn observe(&mut self, ctx: &egui::Context) {
|
pub fn observe(&mut self, ctx: &egui::Context) {
|
||||||
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
|
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
|
||||||
@@ -177,6 +202,7 @@ pub fn show(
|
|||||||
overlay,
|
overlay,
|
||||||
up_next,
|
up_next,
|
||||||
neighbours,
|
neighbours,
|
||||||
|
mut episodes,
|
||||||
} = chrome_state;
|
} = chrome_state;
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
@@ -200,6 +226,8 @@ pub fn show(
|
|||||||
|
|
||||||
if !picture {
|
if !picture {
|
||||||
waiting(ui, playback, rect);
|
waiting(ui, playback, rect);
|
||||||
|
} else if playback.buffering || playback.seeking {
|
||||||
|
stalled(ui, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click anywhere on the picture to pause, which is the one control that
|
// Click anywhere on the picture to pause, which is the one control that
|
||||||
@@ -208,10 +236,18 @@ pub fn show(
|
|||||||
actions.push(Action::Player(Command::TogglePause));
|
actions.push(Action::Player(Command::TogglePause));
|
||||||
}
|
}
|
||||||
if response.double_clicked() {
|
if response.double_clicked() {
|
||||||
|
// The first half of a double click already paused, and a viewer who
|
||||||
|
// double-clicked asked for fullscreen, not for a pause — so the second
|
||||||
|
// half takes the pause back. Waiting out the double-click delay before
|
||||||
|
// pausing would make every single click feel late instead.
|
||||||
|
actions.push(Action::Player(Command::TogglePause));
|
||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
let visible = overlay.visible(&ctx, playback);
|
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
|
||||||
|
// The controls stay up while the list is out: it hangs off the button in
|
||||||
|
// the title strip, and is read at leisure.
|
||||||
|
let visible = overlay.visible(&ctx, playback) || drawer_open;
|
||||||
// Above the chrome when it is up, at the bottom corner when it is not —
|
// Above the chrome when it is up, at the bottom corner when it is not —
|
||||||
// and clear of it either way, which is what the measured height buys.
|
// and clear of it either way, which is what the measured height buys.
|
||||||
let float_above = if visible {
|
let float_above = if visible {
|
||||||
@@ -226,16 +262,37 @@ pub fn show(
|
|||||||
None => skip(ui, playback, rect, float_above, actions),
|
None => skip(ui, playback, rect, float_above, actions),
|
||||||
}
|
}
|
||||||
|
|
||||||
if visible {
|
// Last frame's height, like everything else that has to clear the controls.
|
||||||
overlay.chrome_height = chrome(
|
playback.lift_subtitles(
|
||||||
ui,
|
if visible { overlay.chrome_height } else { 0.0 },
|
||||||
playback,
|
rect.height(),
|
||||||
neighbours,
|
);
|
||||||
rect,
|
|
||||||
overlay.chrome_height,
|
// Faded rather than switched, both ways: controls that blink out under a
|
||||||
actions,
|
// reading eye are more distracting than the ones that were there.
|
||||||
);
|
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE);
|
||||||
|
if fade > 0.0 {
|
||||||
|
overlay.chrome_height = ui
|
||||||
|
.scope(|ui| {
|
||||||
|
ui.multiply_opacity(fade);
|
||||||
|
chrome(
|
||||||
|
ui,
|
||||||
|
playback,
|
||||||
|
neighbours,
|
||||||
|
episodes.as_ref().map(|list| list.open),
|
||||||
|
rect,
|
||||||
|
overlay.chrome_height,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
}
|
||||||
|
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
|
||||||
|
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
|
||||||
} else {
|
} else {
|
||||||
|
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||||
// The controls have to disappear on their own, and nothing else is
|
// The controls have to disappear on their own, and nothing else is
|
||||||
// going to cause a frame while a film plays without the mouse moving.
|
// going to cause a frame while a film plays without the mouse moving.
|
||||||
@@ -287,7 +344,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
|||||||
rect.center() - Vec2::new(0.0, 18.0),
|
rect.center() - Vec2::new(0.0, 18.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
&playback.target.title_name,
|
&playback.target.title_name,
|
||||||
egui::FontId::proportional(20.0),
|
theme::Role::Title.font(),
|
||||||
theme::text(),
|
theme::text(),
|
||||||
);
|
);
|
||||||
painter.text(
|
painter.text(
|
||||||
@@ -298,11 +355,23 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
|||||||
} else {
|
} else {
|
||||||
"playing in mpv's own window"
|
"playing in mpv's own window"
|
||||||
},
|
},
|
||||||
egui::FontId::proportional(13.0),
|
theme::Role::Label.font(),
|
||||||
theme::muted(),
|
theme::muted(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A spinner over a picture held waiting for data. Without it, a frozen frame
|
||||||
|
/// with the controls hidden looks the same as a hang.
|
||||||
|
fn stalled(ui: &mut egui::Ui, rect: Rect) {
|
||||||
|
let disc = Rect::from_center_size(rect.center(), Vec2::splat(64.0));
|
||||||
|
ui.painter()
|
||||||
|
.circle_filled(disc.center(), 32.0, Color32::from_black_alpha(140));
|
||||||
|
ui.put(
|
||||||
|
Rect::from_center_size(disc.center(), Vec2::splat(36.0)),
|
||||||
|
egui::Spinner::new().size(36.0).color(Color32::WHITE),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The screen between two episodes: one has ended and the next is opening.
|
/// The screen between two episodes: one has ended and the next is opening.
|
||||||
///
|
///
|
||||||
/// It keeps a way out, because opening a file over a public link can take
|
/// It keeps a way out, because opening a file over a public link can take
|
||||||
@@ -313,14 +382,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
|||||||
rect.center() - Vec2::new(0.0, 16.0),
|
rect.center() - Vec2::new(0.0, 16.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
"Up next",
|
"Up next",
|
||||||
egui::FontId::proportional(14.0),
|
theme::Role::Body.font(),
|
||||||
theme::muted(),
|
theme::muted(),
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.center() + Vec2::new(0.0, 12.0),
|
rect.center() + Vec2::new(0.0, 12.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
opening.unwrap_or("opening…"),
|
opening.unwrap_or("opening…"),
|
||||||
egui::FontId::proportional(20.0),
|
theme::Role::Title.font(),
|
||||||
theme::text(),
|
theme::text(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -335,7 +404,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
|||||||
if ui.button("Back").clicked() {
|
if ui.button("Back").clicked() {
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -361,7 +430,7 @@ fn skip(
|
|||||||
// recap" are different lengths, and one constant leaves the shorter of them
|
// recap" are different lengths, and one constant leaves the shorter of them
|
||||||
// adrift in a box of its own whitespace, which is what made this read as a
|
// adrift in a box of its own whitespace, which is what made this read as a
|
||||||
// rectangle with some text in it instead of a button.
|
// rectangle with some text in it instead of a button.
|
||||||
let font = egui::FontId::proportional(14.0);
|
let font = theme::Role::Body.font();
|
||||||
let text_width = ui
|
let text_width = ui
|
||||||
.painter()
|
.painter()
|
||||||
.layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE)
|
.layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE)
|
||||||
@@ -428,60 +497,114 @@ fn up_next_card(
|
|||||||
bottom_margin: f32,
|
bottom_margin: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let size = Vec2::new(330.0, 104.0);
|
const STILL: Vec2 = Vec2::new(128.0, 72.0);
|
||||||
|
let size = Vec2::new(400.0, 104.0);
|
||||||
|
|
||||||
floating(ui, rect, size, bottom_margin, |ui| {
|
floating(ui, rect, size, bottom_margin, |ui| {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(Color32::from_black_alpha(215))
|
.fill(Color32::from_black_alpha(215))
|
||||||
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::symmetric(14, 12))
|
.inner_margin(egui::Margin::same(12))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(size.x - 28.0);
|
ui.set_width(size.x - 24.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(
|
// The still, with the countdown running round it: what is
|
||||||
egui::RichText::new("Up next")
|
// coming and when, read in one glance.
|
||||||
.size(12.0)
|
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
|
||||||
.strong()
|
let painter = ui.painter();
|
||||||
.color(Color32::from_white_alpha(200)),
|
let radius = CornerRadius::same(theme::radius::SM);
|
||||||
|
match &card.still {
|
||||||
|
Some(texture) => {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(frame, radius, Color32::WHITE)
|
||||||
|
.with_texture(
|
||||||
|
texture.id(),
|
||||||
|
ui::cover_uv(texture.size_vec2(), frame.size()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
painter.rect_filled(frame, radius, Color32::from_white_alpha(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter.rect_filled(frame, radius, Color32::from_black_alpha(90));
|
||||||
|
countdown_ring(painter, frame.center(), 22.0, card.left);
|
||||||
|
// Rounded up, so a ring that says "1" is never followed by
|
||||||
|
// a second of nothing happening.
|
||||||
|
painter.text(
|
||||||
|
frame.center(),
|
||||||
|
Align2::CENTER_CENTER,
|
||||||
|
card.seconds.ceil() as u32,
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
if response.on_hover_text("Play now").clicked() {
|
||||||
// Rounded up, so a card that says "1s" is never
|
|
||||||
// followed by a second of nothing happening.
|
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
|
|
||||||
.size(12.0)
|
|
||||||
.color(theme::accent()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add(
|
|
||||||
egui::Label::new(
|
|
||||||
egui::RichText::new(&card.caption)
|
|
||||||
.size(14.0)
|
|
||||||
.strong()
|
|
||||||
.color(Color32::WHITE),
|
|
||||||
)
|
|
||||||
.truncate(),
|
|
||||||
);
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui::accent_button(ui, "Play now").clicked() {
|
|
||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
|
||||||
if ui
|
ui.add_space(theme::space::L);
|
||||||
.button("Watch till the end")
|
ui.vertical(|ui| {
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
ui.label(
|
||||||
.clicked()
|
theme::Role::Caption
|
||||||
{
|
.rich("Up next")
|
||||||
actions.push(Action::WatchToEnd);
|
.strong()
|
||||||
}
|
.color(Color32::from_white_alpha(200)),
|
||||||
|
);
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Body
|
||||||
|
.rich(&card.caption)
|
||||||
|
.strong()
|
||||||
|
.color(Color32::WHITE),
|
||||||
|
)
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui::accent_button(ui, "Play now").clicked() {
|
||||||
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
if ui
|
||||||
|
.button("Watch till the end")
|
||||||
|
.on_hover_text("Play this one out — nothing will be skipped")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::WatchToEnd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A ring that empties clockwise from twelve o'clock as `left` runs to zero.
|
||||||
|
fn countdown_ring(painter: &egui::Painter, center: egui::Pos2, radius: f32, left: f32) {
|
||||||
|
painter.circle_stroke(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(3.0, Color32::from_white_alpha(60)),
|
||||||
|
);
|
||||||
|
let left = left.clamp(0.0, 1.0);
|
||||||
|
if left <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let steps = (64.0 * left).ceil().max(2.0) as usize;
|
||||||
|
let sweep = std::f32::consts::TAU * left;
|
||||||
|
let points: Vec<egui::Pos2> = (0..=steps)
|
||||||
|
.map(|step| {
|
||||||
|
let angle = -std::f32::consts::FRAC_PI_2 + sweep * step as f32 / steps as f32;
|
||||||
|
center + radius * Vec2::new(angle.cos(), angle.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(egui::Shape::line(
|
||||||
|
points,
|
||||||
|
egui::Stroke::new(3.0, theme::accent()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
|
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
|
||||||
///
|
///
|
||||||
/// The inset matches the controls' own padding rather than being its own
|
/// The inset matches the controls' own padding rather than being its own
|
||||||
@@ -520,6 +643,7 @@ fn chrome(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
|
drawer: Option<bool>,
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
previous_height: f32,
|
previous_height: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
@@ -533,9 +657,9 @@ fn chrome(
|
|||||||
|ui| {
|
|ui| {
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(egui::RichText::new("Library").size(13.0))
|
egui::Button::new(theme::Role::Label.rich("Library"))
|
||||||
.fill(Color32::from_black_alpha(160))
|
.fill(Color32::from_black_alpha(160))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(96.0, 32.0)),
|
.min_size(Vec2::new(96.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
||||||
@@ -543,34 +667,72 @@ fn chrome(
|
|||||||
{
|
{
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(&playback.target.title_name)
|
theme::Role::Heading
|
||||||
.size(18.0)
|
.rich(&playback.target.title_name)
|
||||||
.strong()
|
.strong()
|
||||||
.color(Color32::WHITE),
|
.color(Color32::WHITE),
|
||||||
);
|
);
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(playback.target.caption())
|
theme::Role::Label
|
||||||
.size(13.0)
|
.rich(playback.target.caption())
|
||||||
.color(Color32::from_white_alpha(190)),
|
.color(Color32::from_white_alpha(190)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(egui::RichText::new("⛶").size(15.0))
|
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
|
||||||
.fill(Color32::from_black_alpha(160))
|
egui_phosphor::regular::CORNERS_IN
|
||||||
.corner_radius(CornerRadius::same(8))
|
} else {
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
egui_phosphor::regular::CORNERS_OUT
|
||||||
|
}))
|
||||||
|
.fill(Color32::from_black_alpha(160))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Fullscreen (F)")
|
.on_hover_text(if fullscreen {
|
||||||
|
"Leave fullscreen (F)"
|
||||||
|
} else {
|
||||||
|
"Fullscreen (F)"
|
||||||
|
})
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
if let Some(open) = drawer {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let fill = if open {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
Color32::from_black_alpha(160)
|
||||||
|
};
|
||||||
|
let ink = if open {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
Color32::WHITE
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::LIST_BULLETS)
|
||||||
|
.color(ink),
|
||||||
|
)
|
||||||
|
.fill(fill)
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Episodes (E)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -609,29 +771,225 @@ fn chrome(
|
|||||||
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
|
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The gradient behind the title strip, fading downwards out of the top edge.
|
/// Which file the episode list last scrolled to, in egui's memory, so it
|
||||||
///
|
/// scrolls to the playing one once when it opens rather than every frame.
|
||||||
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
|
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
|
||||||
/// primitive, and at this size the banding is invisible.
|
|
||||||
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
/// The title's episodes, down the right of the picture between the title
|
||||||
const BANDS: usize = 10;
|
/// strip and the controls. A season at a time, opened on the playing one.
|
||||||
let painter = ui.painter();
|
fn episode_drawer(
|
||||||
let band = rect.height() / BANDS as f32;
|
ctx: &egui::Context,
|
||||||
for index in 0..BANDS {
|
list: &mut Episodes<'_>,
|
||||||
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
|
playback: &Playback,
|
||||||
let strip = Rect::from_min_size(
|
rect: Rect,
|
||||||
egui::pos2(rect.left(), rect.top() + band * index as f32),
|
chrome_height: f32,
|
||||||
Vec2::new(rect.width(), band + 1.0),
|
actions: &mut Vec<Action>,
|
||||||
);
|
) {
|
||||||
painter.rect_filled(
|
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
|
||||||
strip,
|
let area = Rect::from_min_max(
|
||||||
CornerRadius::ZERO,
|
egui::pos2(rect.right() - width - 16.0, rect.top() + TITLE_HEIGHT),
|
||||||
// Squared, so the fade is dark where the text is and gone well
|
egui::pos2(rect.right() - 16.0, rect.bottom() - chrome_height - 8.0),
|
||||||
// before the middle of the picture — a linear ramp over a strip
|
);
|
||||||
// this tall greys the whole top third of the frame.
|
if area.height() < 160.0 {
|
||||||
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
let title = list.title;
|
||||||
|
let target = &playback.target;
|
||||||
|
let is_playing = |episode: &pstr_core::library::Episode| {
|
||||||
|
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
|
||||||
|
};
|
||||||
|
let playing_season = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.position(|season| season.episodes.iter().any(is_playing))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let season_id = egui::Id::new(("episode-drawer-season", &title.key));
|
||||||
|
let mut season = ctx
|
||||||
|
.data(|data| data.get_temp::<usize>(season_id))
|
||||||
|
.unwrap_or(playing_season)
|
||||||
|
.min(title.seasons.len().saturating_sub(1));
|
||||||
|
|
||||||
|
let margin = theme::space::L;
|
||||||
|
egui::Area::new(egui::Id::new("episode-drawer"))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(area.min)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface().gamma_multiply(0.96))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::same(margin as i8))
|
||||||
|
.shadow(theme::tile_shadow(1.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(area.width() - 2.0 * margin);
|
||||||
|
ui.set_height(area.height() - 2.0 * margin);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::X)
|
||||||
|
.color(theme::muted()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.on_hover_text("Close (E)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if title.seasons.len() > 1 {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
let labels: Vec<(usize, String)> = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, season)| (index, season.label()))
|
||||||
|
.collect();
|
||||||
|
let choices: Vec<(usize, &str)> = labels
|
||||||
|
.iter()
|
||||||
|
.map(|(index, label)| (*index, label.as_str()))
|
||||||
|
.collect();
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt("drawer-seasons")
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
|
||||||
|
season = picked;
|
||||||
|
ctx.data_mut(|data| data.insert_temp(season_id, picked));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
|
let Some(shown) = title.seasons.get(season) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("drawer-episodes")
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for episode in &shown.episodes {
|
||||||
|
let row = drawer_row(
|
||||||
|
ui,
|
||||||
|
&mut list.art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
is_playing(episode),
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
if is_playing(episode) {
|
||||||
|
let scrolled = ctx.data(|data| {
|
||||||
|
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
|
||||||
|
});
|
||||||
|
if scrolled.as_deref() != Some(target.link_id.as_str()) {
|
||||||
|
row.scroll_to_me(Some(egui::Align::Center));
|
||||||
|
ctx.data_mut(|data| {
|
||||||
|
data.insert_temp(
|
||||||
|
egui::Id::new(DRAWER_SCROLLED),
|
||||||
|
target.link_id.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode in the list: its still, which plays it, and what it is.
|
||||||
|
fn drawer_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut ui::Art<'_>,
|
||||||
|
title: &pstr_core::library::Title,
|
||||||
|
episode: &pstr_core::library::Episode,
|
||||||
|
playing: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) -> egui::Response {
|
||||||
|
let frame = egui::Frame::new()
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(6))
|
||||||
|
.fill(if playing {
|
||||||
|
theme::accent_dim()
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
});
|
||||||
|
let response = frame
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui::title::still(
|
||||||
|
ui,
|
||||||
|
art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
episode.is_watched(),
|
||||||
|
DRAWER_STILL,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = theme::space::XXS;
|
||||||
|
let name = art
|
||||||
|
.episode(&title.key, episode)
|
||||||
|
.and_then(|found| found.name.clone())
|
||||||
|
.unwrap_or_else(|| episode.detail().to_owned());
|
||||||
|
if let Some(numbering) = episode.numbering() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(numbering)
|
||||||
|
.monospace()
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
name,
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
if episode.is_watched() && !playing {
|
||||||
|
theme::muted()
|
||||||
|
} else {
|
||||||
|
theme::text()
|
||||||
|
},
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 2;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
ui.label(job);
|
||||||
|
let state = if playing {
|
||||||
|
Some("Playing".to_owned())
|
||||||
|
} else if episode.is_watched() {
|
||||||
|
Some("Watched".to_owned())
|
||||||
|
} else {
|
||||||
|
episode
|
||||||
|
.resume_at()
|
||||||
|
.map(|at| format!("Stopped at {}", ui::format_time(at)))
|
||||||
|
};
|
||||||
|
if let Some(state) = state {
|
||||||
|
ui.label(theme::Role::Caption.rich(state).color(if playing {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gradient behind the title strip, fading downwards out of the top edge.
|
||||||
|
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
||||||
|
// Squared, so the fade is dark where the text is and gone well before the
|
||||||
|
// middle of the picture — a linear ramp over a strip this tall greys the
|
||||||
|
// whole top third of the frame.
|
||||||
|
fade(ui.painter(), rect, |t| {
|
||||||
|
let fraction = 1.0 - t;
|
||||||
|
fraction * fraction * 225.0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wash behind the controls: flat over the whole strip, fading in above it.
|
/// The wash behind the controls: flat over the whole strip, fading in above it.
|
||||||
@@ -642,27 +1000,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
|||||||
/// strip itself is one flat wash, and the ramp happens in the empty picture
|
/// strip itself is one flat wash, and the ramp happens in the empty picture
|
||||||
/// above it, where there is nothing to obscure.
|
/// above it, where there is nothing to obscure.
|
||||||
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
|
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
|
||||||
const BANDS: usize = 12;
|
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
strip,
|
strip,
|
||||||
CornerRadius::ZERO,
|
CornerRadius::ZERO,
|
||||||
Color32::from_black_alpha(SCRIM_ALPHA as u8),
|
Color32::from_black_alpha(SCRIM_ALPHA as u8),
|
||||||
);
|
);
|
||||||
|
let above = Rect::from_min_max(
|
||||||
|
egui::pos2(strip.left(), strip.top() - SCRIM_FADE),
|
||||||
|
egui::pos2(strip.right(), strip.top()),
|
||||||
|
);
|
||||||
|
// Squared again: the wash should meet the strip at full strength and be
|
||||||
|
// gone within a finger's width of picture above it.
|
||||||
|
fade(painter, above, |t| t * t * SCRIM_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
let band = SCRIM_FADE / BANDS as f32;
|
/// Black over `rect`, at the alpha `curve` gives for each fraction of the way
|
||||||
for index in 0..BANDS {
|
/// down it.
|
||||||
let fraction = (index + 1) as f32 / BANDS as f32;
|
///
|
||||||
let rect = Rect::from_min_size(
|
/// One mesh with a colour per row of vertices, which the GPU interpolates
|
||||||
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
|
/// between. It replaced a stack of flat bands, and the bands were what showed:
|
||||||
Vec2::new(strip.width(), band + 1.0),
|
/// each overlapped the next by a pixel so no gap could open between them, and
|
||||||
);
|
/// every overlap was a line of double darkness across the picture.
|
||||||
painter.rect_filled(
|
fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
|
||||||
rect,
|
/// Enough rows that the curve between them reads as a curve.
|
||||||
CornerRadius::ZERO,
|
const ROWS: u32 = 16;
|
||||||
// Squared again: the wash should meet the strip at full strength
|
let mut mesh = egui::Mesh::default();
|
||||||
// and be gone within a finger's width of picture above it.
|
for row in 0..=ROWS {
|
||||||
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
|
let t = row as f32 / ROWS as f32;
|
||||||
);
|
let y = egui::lerp(rect.top()..=rect.bottom(), t);
|
||||||
|
let colour = Color32::from_black_alpha(curve(t).clamp(0.0, 255.0) as u8);
|
||||||
|
mesh.colored_vertex(egui::pos2(rect.left(), y), colour);
|
||||||
|
mesh.colored_vertex(egui::pos2(rect.right(), y), colour);
|
||||||
|
if row > 0 {
|
||||||
|
let base = (row - 1) * 2;
|
||||||
|
mesh.add_triangle(base, base + 1, base + 2);
|
||||||
|
mesh.add_triangle(base + 1, base + 3, base + 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
painter.add(egui::Shape::mesh(mesh));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a key just did, in a pill near the top of the picture, for a moment.
|
||||||
|
///
|
||||||
|
/// Returns whether it is still showing.
|
||||||
|
pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool {
|
||||||
|
/// How long it stays, and how much of that is fading out.
|
||||||
|
const SECONDS: f64 = 0.9;
|
||||||
|
const FADE: f64 = 0.25;
|
||||||
|
|
||||||
|
let age = ctx.input(|input| input.time) - at;
|
||||||
|
if age >= SECONDS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32;
|
||||||
|
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||||
|
egui::Order::Foreground,
|
||||||
|
egui::Id::new("osd"),
|
||||||
|
));
|
||||||
|
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE);
|
||||||
|
let size = galley.size() + Vec2::new(36.0, 18.0);
|
||||||
|
let screen = ctx.content_rect();
|
||||||
|
let frame = Rect::from_center_size(
|
||||||
|
egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
frame,
|
||||||
|
CornerRadius::same((size.y / 2.0) as u8),
|
||||||
|
Color32::from_black_alpha((190.0 * opacity) as u8),
|
||||||
|
);
|
||||||
|
painter.galley_with_override_text_color(
|
||||||
|
frame.min + Vec2::new(18.0, 9.0),
|
||||||
|
galley,
|
||||||
|
Color32::WHITE.gamma_multiply(opacity),
|
||||||
|
);
|
||||||
|
ctx.request_repaint();
|
||||||
|
true
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
//! Settings: how the app looks, how it plays, and what it asks the internet.
|
||||||
|
//!
|
||||||
|
//! Split out of the shares page, where these lived because that was the only
|
||||||
|
//! page with a form on it. Nobody looking for the theme looks under the page
|
||||||
|
//! that manages Proton Drive links, and a page that is half link management and
|
||||||
|
//! half preferences is two pages.
|
||||||
|
|
||||||
|
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||||
|
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||||
|
|
||||||
|
use crate::app::Action;
|
||||||
|
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
/// The settings that are a single value rather than a form.
|
||||||
|
///
|
||||||
|
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
|
||||||
|
/// they are read-only, and passing them one by one is what pushed this
|
||||||
|
/// function past the argument count anybody can read.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Prefs {
|
||||||
|
pub autoplay: bool,
|
||||||
|
pub auto_skip: bool,
|
||||||
|
pub appearance: Appearance,
|
||||||
|
pub desktop: DesktopPrefs,
|
||||||
|
/// What the streaming cache holds, once the shares are open.
|
||||||
|
pub cache_used: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widest the settings column gets. Rows that run the width of a 2K window
|
||||||
|
/// put a switch a whole screen away from what it switches.
|
||||||
|
const COLUMN: f32 = 760.0;
|
||||||
|
|
||||||
|
pub fn show(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
settings: &mut MetadataConfig,
|
||||||
|
prefs: Prefs,
|
||||||
|
api_key: &mut String,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// Centred, so a wide window frames the column rather than leaving it
|
||||||
|
// against one edge with the rest of the page empty.
|
||||||
|
let width = ui.available_width().min(COLUMN);
|
||||||
|
let margin = ((ui.available_width() - width) / 2.0).max(0.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(margin);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(width);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
appearance_form(ui, prefs.appearance, actions);
|
||||||
|
playback_form(ui, prefs, actions);
|
||||||
|
storage_form(ui, prefs, actions);
|
||||||
|
metadata_form(ui, settings, api_key, actions);
|
||||||
|
about(ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The theme: a flavour, an accent, and whether the accent is a gradient.
|
||||||
|
///
|
||||||
|
/// Every control here repaints the whole window the moment it is clicked —
|
||||||
|
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||||
|
/// only changed a small square would be asking someone to imagine the result.
|
||||||
|
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||||
|
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
if palette_card(ui, appearance, flavor)
|
||||||
|
.on_hover_text(flavor.description())
|
||||||
|
.clicked()
|
||||||
|
&& appearance.flavor != flavor
|
||||||
|
{
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
flavor,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
|
||||||
|
ui::widgets::settings_row(ui, "Accent", None, |ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 8.0;
|
||||||
|
// Right to left, so reversed to read in the order they are listed.
|
||||||
|
for accent in Accent::ALL.into_iter().rev() {
|
||||||
|
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
accent,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut gradients = appearance.gradients;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Gradient accent",
|
||||||
|
Some(
|
||||||
|
"Fade the accent into a second colour. Off draws it flat, which suits a panel that bands gradients into stripes.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut gradients, "").changed() {
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
gradients,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One palette, as a miniature of the app wearing it: page, a card on it, a
|
||||||
|
/// line of text and the accent.
|
||||||
|
fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> egui::Response {
|
||||||
|
let size = egui::vec2(132.0, 92.0);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let palette = theme::Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
..appearance
|
||||||
|
});
|
||||||
|
let selected = appearance.flavor == flavor;
|
||||||
|
let hover =
|
||||||
|
ui.ctx()
|
||||||
|
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
|
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
|
||||||
|
|
||||||
|
painter.rect_filled(preview, radius, palette.background);
|
||||||
|
let bar = egui::Rect::from_min_size(preview.min, egui::vec2(size.x, 12.0));
|
||||||
|
painter.rect_filled(
|
||||||
|
bar,
|
||||||
|
egui::CornerRadius {
|
||||||
|
nw: 8,
|
||||||
|
ne: 8,
|
||||||
|
sw: 0,
|
||||||
|
se: 0,
|
||||||
|
},
|
||||||
|
palette.surface,
|
||||||
|
);
|
||||||
|
let tile = |x: f32| {
|
||||||
|
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
|
||||||
|
};
|
||||||
|
for x in [8.0, 49.0, 90.0] {
|
||||||
|
painter.rect_filled(
|
||||||
|
tile(x),
|
||||||
|
egui::CornerRadius::same(theme::radius::BAR),
|
||||||
|
palette.card,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pill =
|
||||||
|
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
|
||||||
|
theme::swatch_fill(
|
||||||
|
painter,
|
||||||
|
pill,
|
||||||
|
egui::CornerRadius::same(theme::radius::BAR),
|
||||||
|
&palette,
|
||||||
|
);
|
||||||
|
painter.hline(
|
||||||
|
(preview.left() + 8.0)..=(preview.left() + 60.0),
|
||||||
|
preview.top() + 50.0,
|
||||||
|
egui::Stroke::new(2.0, palette.text),
|
||||||
|
);
|
||||||
|
painter.hline(
|
||||||
|
(preview.left() + 8.0)..=(preview.left() + 40.0),
|
||||||
|
preview.top() + 56.0,
|
||||||
|
egui::Stroke::new(2.0, palette.muted),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ring = if selected {
|
||||||
|
egui::Stroke::new(2.0, theme::accent())
|
||||||
|
} else {
|
||||||
|
egui::Stroke::new(
|
||||||
|
1.0,
|
||||||
|
theme::card_hover().lerp_to_gamma(theme::muted(), hover),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.rect_stroke(preview, radius, ring, egui::StrokeKind::Outside);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.left() + 2.0, preview.bottom() + 8.0),
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
flavor.label(),
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
if selected {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One accent, drawn in the colours it would actually produce.
|
||||||
|
///
|
||||||
|
/// Resolved against the *selected* flavour rather than the active palette, so
|
||||||
|
/// the row previews what clicking it would give rather than what is on screen.
|
||||||
|
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
||||||
|
let selected = appearance.accent == accent;
|
||||||
|
let size = egui::Vec2::splat(26.0);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let palette = theme::Palette::resolve(Appearance {
|
||||||
|
accent,
|
||||||
|
..appearance
|
||||||
|
});
|
||||||
|
let radius = egui::CornerRadius::same(13);
|
||||||
|
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
||||||
|
if selected {
|
||||||
|
// A ring rather than a tick: the tick would have to be drawn in an
|
||||||
|
// ink that reads on eight different fills.
|
||||||
|
ui.painter().circle_stroke(
|
||||||
|
rect.center(),
|
||||||
|
rect.width() / 2.0 + 3.0,
|
||||||
|
egui::Stroke::new(2.0, theme::text()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.on_hover_text(accent.label())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The playback settings that are not per-file.
|
||||||
|
///
|
||||||
|
/// Volume, audio language, subtitle language and speed are all set from the
|
||||||
|
/// player itself, where a viewer can hear the result.
|
||||||
|
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||||
|
ui::widgets::settings_group(
|
||||||
|
ui,
|
||||||
|
"Playback",
|
||||||
|
Some(
|
||||||
|
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let mut autoplay = prefs.autoplay;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Play the next episode",
|
||||||
|
Some("When an episode ends, start the next one. Never after a file that failed."),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut autoplay, "").changed() {
|
||||||
|
actions.push(Action::SetAutoplay(autoplay));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut auto_skip = prefs.auto_skip;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Skip openings and credits",
|
||||||
|
Some(
|
||||||
|
"Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut auto_skip, "").changed() {
|
||||||
|
actions.push(Action::SetAutoSkip(auto_skip));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut desktop = prefs.desktop;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Hardware decoding",
|
||||||
|
Some(
|
||||||
|
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
|
||||||
|
actions.push(Action::SetDesktopPrefs(desktop));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The streaming cache: how big it may get, and a way to empty it.
|
||||||
|
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||||
|
ui::widgets::settings_group(
|
||||||
|
ui,
|
||||||
|
"Storage",
|
||||||
|
Some(
|
||||||
|
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
|
||||||
|
let used = match prefs.cache_used {
|
||||||
|
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
|
||||||
|
None => format!("Up to {budget}"),
|
||||||
|
};
|
||||||
|
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
|
||||||
|
let choices: Vec<(u32, String)> = CACHE_CHOICES
|
||||||
|
.iter()
|
||||||
|
.map(|&gib| (gib, format!("{gib} GiB")))
|
||||||
|
.collect();
|
||||||
|
let labels: Vec<(u32, &str)> = choices
|
||||||
|
.iter()
|
||||||
|
.map(|(gib, label)| (*gib, label.as_str()))
|
||||||
|
.collect();
|
||||||
|
if let Some(cache_gib) =
|
||||||
|
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
|
||||||
|
{
|
||||||
|
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
|
||||||
|
cache_gib,
|
||||||
|
..prefs.desktop
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Clear the streaming cache",
|
||||||
|
Some("Frees the space now. Whatever plays next is fetched again."),
|
||||||
|
|ui| {
|
||||||
|
if ui.button("Clear").clicked() {
|
||||||
|
actions.push(Action::ClearStreamCache);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this is, and which version.
|
||||||
|
fn about(ui: &mut egui::Ui) {
|
||||||
|
ui::widgets::settings_group(ui, "About", None, |ui| {
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"proton-stream",
|
||||||
|
Some("Streams Proton Drive share links without an account or a download step."),
|
||||||
|
|ui| {
|
||||||
|
ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| {
|
||||||
|
ui.label(ui::muted("press ?"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
||||||
|
///
|
||||||
|
/// The privacy note is not decoration and it is not in a tooltip. Turning this
|
||||||
|
/// on sends the titles in someone's library to a third party, which is a thing
|
||||||
|
/// they can only agree to if they are told — so it is stated where the switch
|
||||||
|
/// is, before the switch, in the same size as everything else.
|
||||||
|
fn metadata_form(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
settings: &mut MetadataConfig,
|
||||||
|
api_key: &mut String,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.spacing_mut().item_spacing.y = 6.0;
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
||||||
|
without this every tile is a pair of initials. Turning it on sends the titles \
|
||||||
|
in your library — not your files, and nothing about what you have watched — to \
|
||||||
|
the provider you choose, over HTTPS, each time a new one appears.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
let mut enabled = settings.enabled;
|
||||||
|
if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
|
||||||
|
settings.enabled = enabled;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.enabled {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(ui::muted("Provider"));
|
||||||
|
for provider in ProviderId::ALL {
|
||||||
|
if ui
|
||||||
|
.radio(settings.provider == provider, provider.label())
|
||||||
|
.on_hover_text(provider.description())
|
||||||
|
.clicked()
|
||||||
|
&& settings.provider != provider
|
||||||
|
{
|
||||||
|
settings.provider = provider;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.provider.needs_api_key() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(api_key)
|
||||||
|
.password(true)
|
||||||
|
.hint_text("v3 API key")
|
||||||
|
.desired_width(300.0),
|
||||||
|
);
|
||||||
|
if ui.button("Save key").clicked() {
|
||||||
|
actions.push(Action::SetApiKey {
|
||||||
|
provider: settings.provider,
|
||||||
|
key: std::mem::take(api_key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Stored in your system keyring, never in a config file. Leave the box \
|
||||||
|
empty and save to forget it.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui::accent_button(ui, "Match the library").clicked() {
|
||||||
|
actions.push(Action::MatchTitles { force: false });
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button("Match everything again")
|
||||||
|
.on_hover_text("Ask about every title, including ones already matched")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MatchTitles { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
||||||
|
already stored.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
actions.push(Action::SetMetadataConfig(settings.clone()));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
});
|
||||||
|
}
|
||||||
+131
-279
@@ -7,31 +7,17 @@
|
|||||||
//! typed.
|
//! typed.
|
||||||
|
|
||||||
use pstr_core::Share;
|
use pstr_core::Share;
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
use pstr_core::library::Library;
|
||||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
|
||||||
|
|
||||||
use crate::app::{Action, ShareForm};
|
use crate::app::{Action, ShareForm};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
/// The settings on this page that are a single value rather than a form.
|
|
||||||
///
|
|
||||||
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
|
|
||||||
/// they are read-only, and passing them one by one is what pushed this
|
|
||||||
/// function past the argument count anybody can read.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Prefs {
|
|
||||||
pub autoplay: bool,
|
|
||||||
pub appearance: Appearance,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
shares: &[Share],
|
shares: &[Share],
|
||||||
|
library: &Library,
|
||||||
form: &mut ShareForm,
|
form: &mut ShareForm,
|
||||||
settings: &mut MetadataConfig,
|
|
||||||
prefs: Prefs,
|
|
||||||
api_key: &mut String,
|
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
@@ -39,267 +25,41 @@ pub fn show(
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui::section(ui, "Shares");
|
ui::section(ui, "Shares");
|
||||||
if shares.is_empty() {
|
if shares.is_empty() {
|
||||||
ui.label(ui::muted("No shares yet."));
|
|
||||||
}
|
|
||||||
for share in shares {
|
|
||||||
share_row(ui, share, actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(22.0);
|
|
||||||
add_form(ui, form, actions);
|
|
||||||
|
|
||||||
ui.add_space(28.0);
|
|
||||||
playback_form(ui, prefs.autoplay, actions);
|
|
||||||
|
|
||||||
ui.add_space(28.0);
|
|
||||||
appearance_form(ui, prefs.appearance, actions);
|
|
||||||
|
|
||||||
ui.add_space(28.0);
|
|
||||||
metadata_form(ui, settings, api_key, actions);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The theme: a flavour, an accent, and whether the accent is a gradient.
|
|
||||||
///
|
|
||||||
/// Every control here repaints the whole window the moment it is clicked —
|
|
||||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
|
||||||
/// only changed a small square would be asking someone to imagine the result.
|
|
||||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
|
||||||
ui::section(ui, "Appearance");
|
|
||||||
|
|
||||||
egui::Frame::new()
|
|
||||||
.fill(theme::card())
|
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
|
||||||
.inner_margin(egui::Margin::same(14))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
|
||||||
|
|
||||||
ui.label(ui::muted("Palette"));
|
|
||||||
for flavor in Flavor::ALL {
|
|
||||||
if ui
|
|
||||||
.radio(appearance.flavor == flavor, flavor.label())
|
|
||||||
.on_hover_text(flavor.description())
|
|
||||||
.clicked()
|
|
||||||
&& appearance.flavor != flavor
|
|
||||||
{
|
|
||||||
actions.push(Action::SetAppearance(Appearance {
|
|
||||||
flavor,
|
|
||||||
..appearance
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.label(ui::muted("Accent"));
|
|
||||||
ui.horizontal_wrapped(|ui| {
|
|
||||||
for accent in Accent::ALL {
|
|
||||||
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
|
||||||
actions.push(Action::SetAppearance(Appearance {
|
|
||||||
accent,
|
|
||||||
..appearance
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
let mut gradients = appearance.gradients;
|
|
||||||
if ui
|
|
||||||
.checkbox(&mut gradients, "Fade the accent into a second colour")
|
|
||||||
.on_hover_text(
|
|
||||||
"Off draws it flat — better on a panel that bands a gradient into stripes",
|
|
||||||
)
|
|
||||||
.changed()
|
|
||||||
{
|
|
||||||
actions.push(Action::SetAppearance(Appearance {
|
|
||||||
gradients,
|
|
||||||
..appearance
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One accent, drawn in the colours it would actually produce.
|
|
||||||
///
|
|
||||||
/// Resolved against the *selected* flavour rather than the active palette, so
|
|
||||||
/// the row previews what clicking it would give rather than what is on screen.
|
|
||||||
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
|
||||||
let selected = appearance.accent == accent;
|
|
||||||
let size = egui::Vec2::new(52.0, 26.0);
|
|
||||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
let palette = theme::Palette::resolve(Appearance {
|
|
||||||
accent,
|
|
||||||
..appearance
|
|
||||||
});
|
|
||||||
let radius = egui::CornerRadius::same(6);
|
|
||||||
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
|
||||||
if selected {
|
|
||||||
// A ring rather than a tick: the tick would have to be drawn in an
|
|
||||||
// ink that reads on eight different fills.
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
rect.expand(2.0),
|
|
||||||
egui::CornerRadius::same(8),
|
|
||||||
egui::Stroke::new(2.0, theme::text()),
|
|
||||||
egui::StrokeKind::Outside,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response
|
|
||||||
.on_hover_text(accent.label())
|
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
|
||||||
///
|
|
||||||
/// The privacy note is not decoration and it is not in a tooltip. Turning this
|
|
||||||
/// on sends the titles in someone's library to a third party, which is a thing
|
|
||||||
/// they can only agree to if they are told — so it is stated where the switch
|
|
||||||
/// is, before the switch, in the same size as everything else.
|
|
||||||
/// The handful of playback settings that are not per-file.
|
|
||||||
///
|
|
||||||
/// Volume, audio language and subtitle language are all set from the player
|
|
||||||
/// itself, where a viewer can hear the result — only autoplay has no natural
|
|
||||||
/// home there, because by the time it matters the episode is over.
|
|
||||||
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
|
|
||||||
ui::section(ui, "Playback");
|
|
||||||
|
|
||||||
egui::Frame::new()
|
|
||||||
.fill(theme::card())
|
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
|
||||||
.inner_margin(egui::Margin::same(14))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
|
||||||
|
|
||||||
let mut enabled = autoplay;
|
|
||||||
if ui
|
|
||||||
.checkbox(&mut enabled, "Play the next episode automatically")
|
|
||||||
.on_hover_text("Only when an episode reaches its end, never after a failure")
|
|
||||||
.changed()
|
|
||||||
{
|
|
||||||
actions.push(Action::SetAutoplay(enabled));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"Volume, audio language and subtitles are set from the player, and remembered \
|
|
||||||
from there.",
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn metadata_form(
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
settings: &mut MetadataConfig,
|
|
||||||
api_key: &mut String,
|
|
||||||
actions: &mut Vec<Action>,
|
|
||||||
) {
|
|
||||||
ui::section(ui, "Posters and descriptions");
|
|
||||||
|
|
||||||
egui::Frame::new()
|
|
||||||
.fill(theme::card())
|
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
|
||||||
.inner_margin(egui::Margin::same(14))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
|
||||||
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
|
||||||
without this every tile is a pair of initials. Turning it on sends the titles \
|
|
||||||
in your library — not your files, and nothing about what you have watched — to \
|
|
||||||
the provider you choose, over HTTPS, each time a new one appears.",
|
|
||||||
));
|
|
||||||
ui.add_space(10.0);
|
|
||||||
|
|
||||||
let mut changed = false;
|
|
||||||
let mut enabled = settings.enabled;
|
|
||||||
if ui
|
|
||||||
.checkbox(&mut enabled, "Look up posters and descriptions")
|
|
||||||
.changed()
|
|
||||||
{
|
|
||||||
settings.enabled = enabled;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.enabled {
|
|
||||||
ui.add_space(10.0);
|
|
||||||
ui.label(ui::muted("Provider"));
|
|
||||||
for provider in ProviderId::ALL {
|
|
||||||
if ui
|
|
||||||
.radio(settings.provider == provider, provider.label())
|
|
||||||
.on_hover_text(provider.description())
|
|
||||||
.clicked()
|
|
||||||
&& settings.provider != provider
|
|
||||||
{
|
|
||||||
settings.provider = provider;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.provider.needs_api_key() {
|
|
||||||
ui.add_space(10.0);
|
|
||||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.add(
|
|
||||||
egui::TextEdit::singleline(api_key)
|
|
||||||
.password(true)
|
|
||||||
.hint_text("v3 API key")
|
|
||||||
.desired_width(300.0),
|
|
||||||
);
|
|
||||||
if ui.button("Save key").clicked() {
|
|
||||||
actions.push(Action::SetApiKey {
|
|
||||||
provider: settings.provider,
|
|
||||||
key: std::mem::take(api_key),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"Stored in your system keyring, never in a config file. Leave the box \
|
|
||||||
empty and save to forget it.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui::accent_button(ui, "Match the library").clicked() {
|
|
||||||
actions.push(Action::MatchTitles { force: false });
|
|
||||||
}
|
|
||||||
if ui
|
|
||||||
.button("Match everything again")
|
|
||||||
.on_hover_text("Ask about every title, including ones already matched")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::MatchTitles { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
"No shares yet. Add one below and its contents become your library.",
|
||||||
already stored.",
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
for share in shares {
|
||||||
if changed {
|
let titles = library
|
||||||
actions.push(Action::SetMetadataConfig(settings.clone()));
|
.titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| title.share_ids.contains(&share.id))
|
||||||
|
.count();
|
||||||
|
share_row(ui, share, titles, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
add_form(ui, shares, form, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(egui::RichText::new(&share.name).strong());
|
ui.label(egui::RichText::new(&share.name).strong());
|
||||||
let detail = if share.has_custom_password {
|
// What is in it, rather than what it is called inside the
|
||||||
format!("{} · custom password", share.id)
|
// app. The id is still a hover away for a bug report.
|
||||||
} else {
|
let mut detail = ui::library::plural(titles, "title");
|
||||||
share.id.clone()
|
if share.has_custom_password {
|
||||||
};
|
detail.push_str(" · password protected");
|
||||||
ui.label(ui::muted(detail));
|
}
|
||||||
|
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
@@ -316,20 +76,20 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Add a share");
|
ui::section(ui, "Add a share");
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Paste the whole link, including everything after the # — that part is the key that \
|
"Paste the whole link, including everything after the # — that part is the key that \
|
||||||
decrypts it, and it is stored in your system keyring rather than on disk.",
|
decrypts it, and it is stored in your system keyring rather than on disk.",
|
||||||
));
|
));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::same(14))
|
.inner_margin(egui::Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
@@ -340,20 +100,45 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
.hint_text("Anime")
|
.hint_text("Anime")
|
||||||
.desired_width(f32::INFINITY),
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.add(
|
ui.horizontal(|ui| {
|
||||||
egui::TextEdit::singleline(&mut form.url)
|
let paste = 60.0;
|
||||||
.password(true)
|
let field = egui::Id::new("share-link");
|
||||||
.hint_text("https://drive.proton.me/urls/…#…")
|
ui.add(
|
||||||
.desired_width(f32::INFINITY),
|
egui::TextEdit::singleline(&mut form.url)
|
||||||
);
|
.id(field)
|
||||||
ui.add_space(8.0);
|
.password(true)
|
||||||
|
.hint_text("https://drive.proton.me/urls/…#…")
|
||||||
|
.desired_width(ui.available_width() - paste),
|
||||||
|
);
|
||||||
|
// The link is masked, so pasting is the only sensible way in —
|
||||||
|
// and a button says that more plainly than the field does.
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
[paste - 8.0, ui.spacing().interact_size.y],
|
||||||
|
egui::Button::new("Paste"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
ui.ctx()
|
||||||
|
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
|
||||||
|
// The paste lands in whatever has focus, so give it some.
|
||||||
|
ui.memory_mut(|memory| memory.request_focus(field));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Said while typing, not after a refused submit: everything this
|
||||||
|
// checks is knowable from the text alone.
|
||||||
|
let problem = link_problem(&form.url, shares);
|
||||||
|
if let Some(problem) = &problem {
|
||||||
|
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui.checkbox(&mut form.has_password, "The link asks for a password");
|
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||||
if form.has_password {
|
if form.has_password {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut form.password)
|
egui::TextEdit::singleline(&mut form.password)
|
||||||
.password(true)
|
.password(true)
|
||||||
@@ -362,11 +147,17 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
let ready = !form.name.trim().is_empty()
|
let ready = !form.name.trim().is_empty()
|
||||||
&& !form.url.trim().is_empty()
|
&& !form.url.trim().is_empty()
|
||||||
&& (!form.has_password || !form.password.is_empty());
|
&& problem.is_none()
|
||||||
|
&& (!form.has_password || !form.password.is_empty())
|
||||||
|
&& !form.sending;
|
||||||
|
|
||||||
|
if let Some(error) = &form.error {
|
||||||
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
if ui
|
||||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
||||||
@@ -382,9 +173,70 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
.filter(|password| !password.is_empty()),
|
.filter(|password| !password.is_empty()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !ready {
|
if form.sending {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted("adding…"));
|
||||||
|
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
||||||
ui.label(ui::muted("A name and a link are needed."));
|
ui.label(ui::muted("A name and a link are needed."));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is wrong with a link as typed, if anything.
|
||||||
|
///
|
||||||
|
/// Nothing for an empty box — that is "not done yet", which the button already
|
||||||
|
/// says.
|
||||||
|
fn link_problem(url: &str, shares: &[Share]) -> Option<String> {
|
||||||
|
let url = url.trim();
|
||||||
|
if url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match pstr_core::shares::share_token(url) {
|
||||||
|
Ok(token) if shares.iter().any(|share| share.token == token) => {
|
||||||
|
Some("That share is already added.".into())
|
||||||
|
}
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(error) => Some(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn share(token: &str) -> Share {
|
||||||
|
Share {
|
||||||
|
id: format!("share-{token}"),
|
||||||
|
name: "anime".into(),
|
||||||
|
token: token.into(),
|
||||||
|
has_custom_password: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_link_is_not_yet_a_problem() {
|
||||||
|
assert_eq!(link_problem(" ", &[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_without_its_key_is_refused_while_typing() {
|
||||||
|
assert!(link_problem("https://drive.proton.me/urls/ABC123", &[]).is_some());
|
||||||
|
assert!(link_problem("https://example.com/", &[]).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
link_problem("https://drive.proton.me/urls/ABC123#key", &[]),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_already_added_is_refused_while_typing() {
|
||||||
|
assert!(
|
||||||
|
link_problem(
|
||||||
|
"https://drive.proton.me/urls/ABC123#key",
|
||||||
|
&[share("ABC123")]
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! The list of keyboard shortcuts, on `?`.
|
||||||
|
//!
|
||||||
|
//! Written out here rather than generated from the handlers, because the
|
||||||
|
//! handlers are split between the player page and every other page, and what a
|
||||||
|
//! viewer needs is one list grouped by where they are.
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
||||||
|
(
|
||||||
|
"Anywhere",
|
||||||
|
&[
|
||||||
|
("Ctrl F or /", "Search the library"),
|
||||||
|
("F5 or Ctrl R", "Crawl every share again"),
|
||||||
|
(
|
||||||
|
"Ctrl 1 – 5",
|
||||||
|
"Library, History, Shares, Downloads, Settings",
|
||||||
|
),
|
||||||
|
("Ctrl ,", "Settings"),
|
||||||
|
("Ctrl V", "Paste a share link to add it"),
|
||||||
|
("Tab or arrows", "Move between titles and episodes"),
|
||||||
|
("Enter", "Open or play what is highlighted"),
|
||||||
|
("Alt ← or mouse back", "Back"),
|
||||||
|
("?", "This list"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Playing",
|
||||||
|
&[
|
||||||
|
("Space or K", "Pause and play"),
|
||||||
|
("← / →", "Back 10 s, forward 30 s"),
|
||||||
|
("↑ / ↓", "Volume"),
|
||||||
|
("Ctrl S", "Save the frame to Pictures/proton-stream"),
|
||||||
|
("M", "Mute"),
|
||||||
|
("N / P", "Next and previous episode"),
|
||||||
|
("[ / ]", "Slower and faster"),
|
||||||
|
("S", "Skip the opening or the credits"),
|
||||||
|
("E", "The list of episodes"),
|
||||||
|
("F or double-click", "Fullscreen"),
|
||||||
|
("Esc", "Leave fullscreen, then the player"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Draw the list. Returns whether it asked to be closed.
|
||||||
|
pub fn show(ctx: &egui::Context) -> bool {
|
||||||
|
let mut close = false;
|
||||||
|
let modal = egui::Modal::new(egui::Id::new("shortcuts"))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.inner_margin(egui::Margin::same(20))
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(460.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Heading.rich("Keyboard shortcuts").strong());
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (heading, keys) in GROUPS {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(ui::muted(heading.to_uppercase()));
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
egui::Grid::new(*heading)
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([18.0, 8.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (key, what) in *keys {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Label
|
||||||
|
.rich(*key)
|
||||||
|
.monospace()
|
||||||
|
.color(theme::accent()),
|
||||||
|
);
|
||||||
|
ui.label(theme::Role::Body.rich(*what));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
close || modal.should_close()
|
||||||
|
}
|
||||||
+638
-231
@@ -30,61 +30,153 @@ pub fn show(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if ui.button("Library").clicked() {
|
if back_link(ui).clicked() {
|
||||||
actions.push(Action::Goto(Page::Library));
|
actions.push(Action::Goto(Page::Library));
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
header(ui, art, title, &offline, actions);
|
header(ui, art, title, &offline, actions);
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
|
|
||||||
for (index, season) in title.seasons.iter().enumerate() {
|
// One season at a time, picked from a row of pills, rather than
|
||||||
let open = index == 0 || title.seasons.len() == 1;
|
// every season stacked as a collapsing header: a four-season show
|
||||||
egui::CollapsingHeader::new(
|
// was a page of headers to scroll past to reach the one being
|
||||||
egui::RichText::new(format!(
|
// watched. The one opened first is the one with the next episode.
|
||||||
"{} · {}",
|
let picked_id = ui.id().with(("season", &title.key));
|
||||||
season.label(),
|
let default = title
|
||||||
ui::library::plural(season.episodes.len(), "episode")
|
.next_up()
|
||||||
))
|
.and_then(|next| {
|
||||||
.size(15.0)
|
title.seasons.iter().position(|season| {
|
||||||
.strong(),
|
season
|
||||||
)
|
.episodes
|
||||||
.id_salt(("season", index))
|
.iter()
|
||||||
.default_open(open)
|
.any(|episode| episode.node.link_id == next.node.link_id)
|
||||||
.show(ui, |ui| {
|
})
|
||||||
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
|
})
|
||||||
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
|
.unwrap_or(0);
|
||||||
if all_offline {
|
let mut picked = ui
|
||||||
if ui.button("Make season online-only").clicked() {
|
.data(|data| data.get_temp::<usize>(picked_id))
|
||||||
for key in season_keys {
|
.unwrap_or(default)
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
.min(title.seasons.len().saturating_sub(1));
|
||||||
}
|
|
||||||
}
|
let Some(season) = title.seasons.get(picked) else {
|
||||||
} else if ui
|
return;
|
||||||
.button("Make season offline")
|
};
|
||||||
.on_hover_text("Download every episode in this season")
|
ui.horizontal(|ui| {
|
||||||
.clicked()
|
if title.seasons.len() > 1 {
|
||||||
{
|
let labels: Vec<String> =
|
||||||
actions.push(Action::MakeOffline(
|
title.seasons.iter().map(|season| season.label()).collect();
|
||||||
season
|
let choices: Vec<(usize, &str)> = labels
|
||||||
.episodes
|
.iter()
|
||||||
.iter()
|
.enumerate()
|
||||||
.map(|episode| PlaybackTarget::new(title, episode))
|
.map(|(index, label)| (index, label.as_str()))
|
||||||
.collect(),
|
.collect();
|
||||||
));
|
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
|
||||||
}
|
picked = index;
|
||||||
for episode in &season.episodes {
|
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(ui::library::plural(
|
||||||
|
season.episodes.len(),
|
||||||
|
"episode",
|
||||||
|
)));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
season_download(ui, title, season, &offline, actions);
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
});
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
let Some(season) = title.seasons.get(picked) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for episode in &season.episodes {
|
||||||
|
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A title's poster, at the poster's own shape, with how far into it the
|
||||||
|
/// viewer is along its foot.
|
||||||
|
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
|
||||||
|
let size = egui::vec2(180.0, 270.0);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
|
ui.painter()
|
||||||
|
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
|
||||||
|
ui.painter().add(
|
||||||
|
egui::epaint::RectShape::filled(rect, radius, egui::Color32::WHITE)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), size)),
|
||||||
|
);
|
||||||
|
if let Some(progress) = progress {
|
||||||
|
let track = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left(), rect.bottom() - 4.0),
|
||||||
|
rect.right_bottom(),
|
||||||
|
);
|
||||||
|
let painter = ui.painter().with_clip_rect(track);
|
||||||
|
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
|
||||||
|
let mut played = rect;
|
||||||
|
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
|
||||||
|
theme::accent_fill(&painter, played, radius, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "← Library", as a link rather than a button: it is navigation, and a
|
||||||
|
/// filled button beside the page's real actions competed with them.
|
||||||
|
fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||||
|
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
|
||||||
|
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
||||||
|
if response.hovered() {
|
||||||
|
ui.painter().hline(
|
||||||
|
response.rect.x_range(),
|
||||||
|
response.rect.bottom(),
|
||||||
|
egui::Stroke::new(1.0, theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Back (Esc, Alt ←)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download, or delete, a whole season.
|
||||||
|
fn season_download(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
title: &Title,
|
||||||
|
season: &Season,
|
||||||
|
offline: &OfflineView<'_>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
|
||||||
|
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
|
||||||
|
if all_offline {
|
||||||
|
if ui
|
||||||
|
.button("Delete season")
|
||||||
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
for key in season_keys {
|
||||||
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
|
.button("Download season")
|
||||||
|
.on_hover_text("Download every episode in this season")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(
|
||||||
|
season
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.map(|episode| PlaybackTarget::new(title, episode))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The still, the name and the one button that matters.
|
/// The still, the name and the one button that matters.
|
||||||
fn header(
|
fn header(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -98,22 +190,46 @@ fn header(
|
|||||||
let record = art.metadata.get(&title.key);
|
let record = art.metadata.get(&title.key);
|
||||||
let found = record.and_then(|record| record.metadata.clone());
|
let found = record.and_then(|record| record.metadata.clone());
|
||||||
let by_hand = record.is_some_and(|record| record.manual);
|
let by_hand = record.is_some_and(|record| record.manual);
|
||||||
|
// Asked for once and used twice: the same picture is the poster beside the
|
||||||
|
// text and the banner behind all of it.
|
||||||
|
let picture = art.of(title);
|
||||||
|
|
||||||
|
// Reserved before the header is laid out, because the band is as tall as
|
||||||
|
// whatever the header comes to — a fixed height would clip a long overview
|
||||||
|
// on one title and leave a gap under a short one on the next.
|
||||||
|
let backdrop = picture
|
||||||
|
.as_ref()
|
||||||
|
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
||||||
|
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
ui::card(
|
match &picture {
|
||||||
ui,
|
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
|
||||||
Card {
|
// uses, it was a narrow strip of picture between two bars.
|
||||||
art: art.of(title),
|
Some((texture, pstr_core::metadata::ArtShape::Portrait)) => {
|
||||||
name: &title.name,
|
poster(ui, texture, title.resume().and_then(|e| e.progress()));
|
||||||
subtitle: String::new(),
|
}
|
||||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
_ => {
|
||||||
badge: None,
|
ui::card(
|
||||||
},
|
ui,
|
||||||
);
|
Card {
|
||||||
|
art: picture,
|
||||||
|
name: &title.name,
|
||||||
|
subtitle: String::new(),
|
||||||
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
|
badge: None,
|
||||||
|
width: theme::CARD_WIDTH,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XXL);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(egui::RichText::new(&title.name).size(26.0).strong());
|
// Text in a column a person can read, with the picture to the
|
||||||
|
// right of it rather than under it.
|
||||||
|
ui.set_max_width(ui.available_width().min(820.0));
|
||||||
|
ui.label(theme::Role::Display.rich(&title.name).strong());
|
||||||
// The provider's name for it, when it is not the one the files use.
|
// The provider's name for it, when it is not the one the files use.
|
||||||
// Worth showing rather than replacing the filename's: a viewer
|
// Worth showing rather than replacing the filename's: a viewer
|
||||||
// should be able to tell what the match actually matched.
|
// should be able to tell what the match actually matched.
|
||||||
@@ -126,16 +242,24 @@ fn header(
|
|||||||
if by_hand { " (chosen by hand)" } else { "" }
|
if by_hand { " (chosen by hand)" } else { "" }
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(episode) = title.next_up() {
|
if let Some(episode) = title.next_up() {
|
||||||
let resuming = episode.resume_at().is_some();
|
let resuming = episode.resume_at().is_some();
|
||||||
|
// Where it resumes, in the button that resumes it. As a
|
||||||
|
// label of its own it ended up three controls further
|
||||||
|
// along, after the download buttons, reading as a fact
|
||||||
|
// about those.
|
||||||
|
let at = episode
|
||||||
|
.resume_at()
|
||||||
|
.map(|at| format!(" · {}", ui::format_time(at)))
|
||||||
|
.unwrap_or_default();
|
||||||
let label = match (resuming, episode.numbering()) {
|
let label = match (resuming, episode.numbering()) {
|
||||||
(true, Some(numbering)) => format!("Resume {numbering}"),
|
(true, Some(numbering)) => format!("Resume {numbering}{at}"),
|
||||||
(true, None) => "Resume".to_string(),
|
(true, None) => format!("Resume{at}"),
|
||||||
(false, Some(numbering)) if title.kind == TitleKind::Series => {
|
(false, Some(numbering)) if title.kind == TitleKind::Series => {
|
||||||
format!("Play {numbering}")
|
format!("Play {numbering}")
|
||||||
}
|
}
|
||||||
@@ -144,16 +268,33 @@ fn header(
|
|||||||
if ui::accent_button(ui, &label).clicked() {
|
if ui::accent_button(ui, &label).clicked() {
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
}
|
}
|
||||||
|
// Beside the button it is the alternative to.
|
||||||
|
if resuming
|
||||||
|
&& ui
|
||||||
|
.button("Start over")
|
||||||
|
.on_hover_text("Play from the beginning")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Play(PlaybackTarget::from_node(
|
||||||
|
title,
|
||||||
|
&episode.node,
|
||||||
|
None,
|
||||||
|
)));
|
||||||
|
}
|
||||||
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||||
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||||
if all_offline {
|
if all_offline {
|
||||||
if ui.button("Make show online-only").clicked() {
|
if ui
|
||||||
|
.button("Delete show")
|
||||||
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
for key in title_keys {
|
for key in title_keys {
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if ui
|
} else if ui
|
||||||
.button("Make show offline")
|
.button("Download show")
|
||||||
.on_hover_text("Download every episode for disconnected playback")
|
.on_hover_text("Download every episode for disconnected playback")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -182,20 +323,6 @@ fn header(
|
|||||||
} else if all_offline {
|
} else if all_offline {
|
||||||
ui.label(ui::muted("Available offline"));
|
ui.label(ui::muted("Available offline"));
|
||||||
}
|
}
|
||||||
if resuming && let Some(at) = episode.resume_at() {
|
|
||||||
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
|
|
||||||
if ui
|
|
||||||
.button("Start over")
|
|
||||||
.on_hover_text("Play from the beginning")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::Play(PlaybackTarget::from_node(
|
|
||||||
title,
|
|
||||||
&episode.node,
|
|
||||||
None,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The way out of a match the scorer would not make, or made
|
// The way out of a match the scorer would not make, or made
|
||||||
@@ -215,31 +342,161 @@ fn header(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Some(found) = &found {
|
if let Some(found) = &found {
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
description(ui, found);
|
description(ui, found);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some((texture, index)) = backdrop {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
band(ui, &texture, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picture again, across the whole width, behind the header.
|
||||||
|
///
|
||||||
|
/// The one piece of this page that had to wait for artwork to exist at all: the
|
||||||
|
/// providers already hand back a picture and its shape, so a backdrop costs no
|
||||||
|
/// new request — only somewhere to put it.
|
||||||
|
///
|
||||||
|
/// Three shapes over one another, in this order: the picture, a flat veil of the
|
||||||
|
/// page colour so text laid over it stays readable whatever the still happens to
|
||||||
|
/// be, and a fade into the page along the bottom so the band ends rather than
|
||||||
|
/// stops.
|
||||||
|
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
|
||||||
|
// Full width of the window, not of the content: the panel's own margin is
|
||||||
|
// what the band has to reach past, or it reads as a picture in a box.
|
||||||
|
const BLEED: f32 = 18.0;
|
||||||
|
let content = ui.min_rect();
|
||||||
|
let rect = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
|
||||||
|
egui::pos2(ui.max_rect().right() + BLEED, content.bottom()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past the panel margin, or the bleed is trimmed off at exactly the edge it
|
||||||
|
// exists to cross.
|
||||||
|
let painter = ui.painter().with_clip_rect(rect);
|
||||||
|
// Every layer of the band goes into the one slot reserved before the header
|
||||||
|
// was laid out. Painting the veils here, after the fact, would put them over
|
||||||
|
// the name and the overview as well as the still — which is the picture
|
||||||
|
// being readable at the cost of the text it exists behind.
|
||||||
|
let mut layers: Vec<egui::Shape> = vec![
|
||||||
|
egui::epaint::RectShape::filled(rect, egui::CornerRadius::ZERO, egui::Color32::WHITE)
|
||||||
|
.with_texture(
|
||||||
|
texture.id(),
|
||||||
|
super::cover_uv(texture.size_vec2(), rect.size()),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
];
|
||||||
|
// Heavier than it looks: a still is arbitrary, and the one it lands on may
|
||||||
|
// be a white frame. This is the difference between a hero and an unreadable
|
||||||
|
// page.
|
||||||
|
//
|
||||||
|
// Two coats rather than one heavy one. The first is flat and near-opaque —
|
||||||
|
// enough on its own that body text over the busiest frame still reads. The
|
||||||
|
// second is the picture's own contrast being knocked back further behind the
|
||||||
|
// left half, where the poster, the name and the overview actually sit; the
|
||||||
|
// right half keeps more of the still, so the band is still a picture.
|
||||||
|
//
|
||||||
|
// Heavier still when there is no fade coming, since then these are the only
|
||||||
|
// things standing between the text and the picture.
|
||||||
|
let ramps = theme::ramps_on();
|
||||||
|
layers.push(
|
||||||
|
egui::epaint::RectShape::filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::ZERO,
|
||||||
|
// Lighter with the ramps on: the horizontal one below is solid
|
||||||
|
// where the text is, so this coat only has to calm the picture's
|
||||||
|
// right side, not make it disappear.
|
||||||
|
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
if ramps {
|
||||||
|
let text_side = egui::Rect::from_min_max(
|
||||||
|
rect.min,
|
||||||
|
egui::pos2(rect.left() + rect.width() * 0.72, rect.bottom()),
|
||||||
|
);
|
||||||
|
layers.push(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
text_side,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Horizontal,
|
||||||
|
// Solid at the left, where the poster and the text are.
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A viewer with ramps off is on a panel that bands them; a 120-point ramp
|
||||||
|
// of one hue is exactly the case that shows it. The band then ends at its
|
||||||
|
// own edge, which is the honest version of the same thing.
|
||||||
|
if ramps {
|
||||||
|
let fade =
|
||||||
|
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max);
|
||||||
|
layers.push(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
fade,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Vertical,
|
||||||
|
// Solid at the bottom, where the page it hands over to is.
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
painter.set(index, egui::Shape::Vec(layers));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the provider had to say. Only ever drawn under a match.
|
/// What the provider had to say. Only ever drawn under a match.
|
||||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||||
if !found.genres.is_empty() {
|
if !found.genres.is_empty() {
|
||||||
ui.label(ui::muted(found.genres.join(" · ")));
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.add_space(6.0);
|
ui.spacing_mut().item_spacing.x = 6.0;
|
||||||
|
for genre in &found.genres {
|
||||||
|
ui::widgets::chip(ui, genre);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
}
|
}
|
||||||
if let Some(overview) = &found.overview {
|
if let Some(overview) = &found.overview {
|
||||||
ui.add(
|
// Four lines, then "More". A provider's synopsis runs to three
|
||||||
egui::Label::new(
|
// paragraphs on a long show, and all of them pushed the episodes —
|
||||||
egui::RichText::new(overview)
|
// what the page is for — below the fold.
|
||||||
.size(13.0)
|
const LINES: usize = 4;
|
||||||
.color(theme::text()),
|
let id = ui.id().with(("overview", &found.remote_id));
|
||||||
)
|
let open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
|
||||||
.wrap(),
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
overview.clone(),
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
theme::text(),
|
||||||
|
ui.available_width(),
|
||||||
);
|
);
|
||||||
|
if !open {
|
||||||
|
job.wrap.max_rows = LINES;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
}
|
||||||
|
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
|
||||||
|
let clipped = galley.elided;
|
||||||
|
ui.label(galley);
|
||||||
|
if clipped || open {
|
||||||
|
let toggle = ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich(if open { "Less" } else { "More" })
|
||||||
|
.color(theme::accent()),
|
||||||
|
)
|
||||||
|
.sense(egui::Sense::click()),
|
||||||
|
);
|
||||||
|
if toggle
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
ui.data_mut(|data| data.insert_temp(id, !open));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(url) = &found.url {
|
if let Some(url) = &found.url {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.hyperlink_to(
|
ui.hyperlink_to(
|
||||||
ui::muted(format!("More on {}", found.provider.label())),
|
ui::muted(format!("More on {}", found.provider.label())),
|
||||||
url,
|
url,
|
||||||
@@ -275,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
|
|
||||||
if let Some(found) = found {
|
if let Some(found) = found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("★ {rating:.1}"));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
// Only when it disagrees with what is on disk: "25 episodes · 25
|
// Only when it disagrees with what is on disk: "25 episodes · 25
|
||||||
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
|
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
|
||||||
@@ -290,7 +547,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per file: click anywhere on it to play.
|
/// How big an episode's still is drawn.
|
||||||
|
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
||||||
|
|
||||||
|
/// One row per file: its still, its name and what it is about, and the two
|
||||||
|
/// things that can be done to it besides playing it.
|
||||||
fn episode_row(
|
fn episode_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
@@ -309,165 +570,311 @@ fn episode_row(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let watched = episode.is_watched();
|
let watched = episode.is_watched();
|
||||||
egui::Frame::new()
|
let row_id = ui.id().with(("episode", &episode.node.link_id));
|
||||||
.fill(if watched {
|
|
||||||
theme::background()
|
|
||||||
} else {
|
|
||||||
theme::card()
|
|
||||||
})
|
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
|
||||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width());
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
// An explicit target rather than a clickable row: the row also
|
|
||||||
// carries a checkbox, and a click that means "seen" must never
|
|
||||||
// be one that starts a 1.4 GiB stream instead.
|
|
||||||
if ui
|
|
||||||
.add(
|
|
||||||
egui::Button::new(egui::RichText::new("▶").size(13.0))
|
|
||||||
.fill(theme::card_hover()),
|
|
||||||
)
|
|
||||||
.on_hover_text("Play")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
|
||||||
}
|
|
||||||
let key = key_of(episode);
|
|
||||||
let current = offline.downloads.iter().find(|item| item.key == key);
|
|
||||||
if offline.files.contains(&key) {
|
|
||||||
if ui
|
|
||||||
.small_button("Online-only")
|
|
||||||
.on_hover_text("Delete the offline copy; keep the online source")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::RemoveDownload(key.clone(), false));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted("✓ Offline"));
|
|
||||||
} else if let Some(item) = current {
|
|
||||||
match item.state {
|
|
||||||
DownloadState::Running | DownloadState::Queued => {
|
|
||||||
if ui.small_button("Pause").clicked() {
|
|
||||||
actions.push(Action::PauseDownload(key.clone()));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0)));
|
|
||||||
}
|
|
||||||
DownloadState::Paused => {
|
|
||||||
if ui.small_button("Resume").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(key.clone()));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted("Paused · partial kept"));
|
|
||||||
}
|
|
||||||
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
|
||||||
if ui.small_button("Resume").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(key.clone()));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted("Partial"));
|
|
||||||
}
|
|
||||||
DownloadState::Completed => {
|
|
||||||
ui.label(ui::muted("✓ Offline"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ui
|
|
||||||
.small_button("Download")
|
|
||||||
.on_hover_text("Make available offline")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
|
|
||||||
title, episode,
|
|
||||||
)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
let numbering = episode
|
let frame = egui::Frame::new()
|
||||||
.numbering()
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
|
||||||
.or_else(|| season.number.map(|number| format!("S{number:02}")))
|
.inner_margin(egui::Margin::same(8));
|
||||||
.unwrap_or_default();
|
let mut prepared = frame.begin(ui);
|
||||||
ui.add_sized(
|
{
|
||||||
[66.0, 18.0],
|
let ui = &mut prepared.content_ui;
|
||||||
egui::Label::new(
|
ui.set_width(ui.available_width());
|
||||||
egui::RichText::new(numbering)
|
ui.horizontal_top(|ui| {
|
||||||
.monospace()
|
still(ui, art, title, episode, watched, STILL, actions);
|
||||||
.color(theme::muted()),
|
ui.add_space(theme::space::S);
|
||||||
),
|
|
||||||
);
|
let controls = 96.0;
|
||||||
|
let text_width = (ui.available_width() - controls).max(120.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(text_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = 3.0;
|
||||||
|
|
||||||
// The provider's name for the episode, when there is one:
|
// The provider's name for the episode, when there is one:
|
||||||
// "The Immortal Legion" reads as an episode, and
|
// "The Immortal Legion" reads as an episode, and
|
||||||
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
|
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
|
||||||
|
let numbering = episode
|
||||||
|
.numbering()
|
||||||
|
.or_else(|| season.number.map(|number| format!("S{number:02}")))
|
||||||
|
.unwrap_or_default();
|
||||||
let name = found
|
let name = found
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|(name, _, _)| name.clone())
|
.and_then(|(name, _, _)| name.clone())
|
||||||
.unwrap_or_else(|| episode.detail().to_string());
|
.unwrap_or_else(|| episode.detail().to_string());
|
||||||
let label = egui::RichText::new(name).color(if watched {
|
let mut job = egui::text::LayoutJob::default();
|
||||||
theme::muted()
|
if !numbering.is_empty() {
|
||||||
} else {
|
job.append(
|
||||||
theme::text()
|
&format!("{numbering} "),
|
||||||
});
|
0.0,
|
||||||
let hover = match &found {
|
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
|
||||||
// The synopsis, where the provider has one — AniList does
|
);
|
||||||
// not, and the filename is worth showing either way.
|
|
||||||
Some((_, Some(overview), _)) => {
|
|
||||||
format!("{}\n\n{overview}", episode.node.name)
|
|
||||||
}
|
|
||||||
_ => episode.node.name.clone(),
|
|
||||||
};
|
|
||||||
ui.add(egui::Label::new(label).truncate())
|
|
||||||
.on_hover_text(hover);
|
|
||||||
|
|
||||||
if let Some((_, _, Some(air_date))) = &found {
|
|
||||||
ui.label(ui::muted(air_date.clone()));
|
|
||||||
}
|
}
|
||||||
|
job.append(
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
&name,
|
||||||
let mut checked = watched;
|
0.0,
|
||||||
if ui
|
egui::TextFormat::simple(
|
||||||
.checkbox(&mut checked, "")
|
theme::Role::Body.font(),
|
||||||
.on_hover_text(if watched {
|
if watched {
|
||||||
"Mark unwatched"
|
theme::muted()
|
||||||
} else {
|
} else {
|
||||||
"Mark watched"
|
theme::text()
|
||||||
})
|
},
|
||||||
.changed()
|
),
|
||||||
{
|
);
|
||||||
actions.push(Action::SetWatched {
|
job.wrap.max_rows = 1;
|
||||||
share_id: episode.node.share_id.clone(),
|
job.wrap.overflow_character = Some('…');
|
||||||
link_id: episode.node.link_id.clone(),
|
ui.add(egui::Label::new(job).truncate())
|
||||||
watched: checked,
|
.on_hover_text(&episode.node.name);
|
||||||
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match episode.node.size {
|
ui.label(ui::muted(details(
|
||||||
// A file the share says is empty is one that will not
|
episode,
|
||||||
// play — an upload that never finished, usually. Worth
|
found.as_ref().and_then(|f| f.2.as_deref()),
|
||||||
// saying so on the row rather than only when a click on
|
)));
|
||||||
// it comes back with "has no content".
|
|
||||||
Some(0) => {
|
if let Some((_, Some(overview), _)) = &found {
|
||||||
ui.label(
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
egui::RichText::new("empty")
|
overview.clone(),
|
||||||
.size(12.0)
|
theme::Role::Caption.font(),
|
||||||
.color(theme::danger()),
|
theme::muted(),
|
||||||
)
|
text_width,
|
||||||
.on_hover_text(
|
);
|
||||||
"The share reports no content for this file; it cannot be played.",
|
job.wrap.max_rows = 2;
|
||||||
);
|
job.wrap.overflow_character = Some('…');
|
||||||
}
|
ui.add(egui::Label::new(job)).on_hover_text(overview);
|
||||||
Some(size) => {
|
}
|
||||||
ui.label(ui::muted(ui::format_size(size)));
|
});
|
||||||
}
|
|
||||||
None => {}
|
// A fixed height, the still's. `with_layout` would hand the
|
||||||
}
|
// buttons all the height left in the scroll area's viewport and
|
||||||
if let Some(at) = episode.resume_at() {
|
// centre them in it, so whichever row sat highest on screen
|
||||||
ui.label(
|
// stretched down to the bottom of the window.
|
||||||
egui::RichText::new(format!("resume {}", ui::format_time(at)))
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
.size(12.0)
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
.color(theme::accent()),
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
);
|
if ui::widgets::watched_mark(ui, watched)
|
||||||
}
|
.on_hover_text(if watched {
|
||||||
});
|
"Mark unwatched"
|
||||||
|
} else {
|
||||||
|
"Mark watched"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetWatched {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
watched: !watched,
|
||||||
|
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
download(ui, title, episode, offline, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
}
|
||||||
|
// The row lights up under the pointer, so it is clear which file the
|
||||||
|
// buttons on the right belong to in a list of thirty.
|
||||||
|
let response = prepared.allocate_space(ui);
|
||||||
|
let lit = ui.ctx().animate_bool_with_time(
|
||||||
|
row_id,
|
||||||
|
response.hovered() || response.contains_pointer(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
||||||
|
prepared.paint(ui);
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grey line under an episode's name: when it aired, how big it is, and
|
||||||
|
/// where it would resume.
|
||||||
|
fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(at) = episode.resume_at() {
|
||||||
|
let left = episode
|
||||||
|
.watch
|
||||||
|
.and_then(|watch| watch.duration_secs)
|
||||||
|
.map(|duration| format!(" · {} left", ui::format_time(duration - at)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts.push(format!("Resume at {}{left}", ui::format_time(at)));
|
||||||
|
} else if episode.is_watched() {
|
||||||
|
parts.push("Watched".into());
|
||||||
|
}
|
||||||
|
if let Some(date) = air_date {
|
||||||
|
parts.push(date.to_owned());
|
||||||
|
}
|
||||||
|
match episode.node.size {
|
||||||
|
// A file the share says is empty is one that will not play — an upload
|
||||||
|
// that never finished, usually. Worth saying on the row rather than
|
||||||
|
// only when a click on it comes back with "has no content".
|
||||||
|
Some(0) => parts.push("empty — this file cannot be played".into()),
|
||||||
|
Some(size) => parts.push(ui::format_size(size)),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The still, which is also the play button.
|
||||||
|
///
|
||||||
|
/// An explicit target rather than a clickable row: the row also carries the
|
||||||
|
/// watched mark and the download button, and a click that meant either of
|
||||||
|
/// those must never be one that starts a 1.4 GiB stream instead.
|
||||||
|
pub(crate) fn still(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
episode: &Episode,
|
||||||
|
watched: bool,
|
||||||
|
size: egui::Vec2,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
|
let painter = ui.painter();
|
||||||
|
painter.rect_filled(rect, radius, theme::card_hover());
|
||||||
|
// Asked for only once the row is on screen: a season of twenty-five is
|
||||||
|
// twenty-five requests, and most of them are never scrolled to.
|
||||||
|
if let Some(texture) = art.still(&title.key, episode) {
|
||||||
|
let tint = if watched {
|
||||||
|
egui::Color32::from_gray(150)
|
||||||
|
} else {
|
||||||
|
egui::Color32::WHITE
|
||||||
|
};
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(rect, radius, tint)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.gained_focus() {
|
||||||
|
response.scroll_to_me(None);
|
||||||
|
}
|
||||||
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id,
|
||||||
|
response.hovered() || response.has_focus(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
if hover > 0.0 {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Color32::from_black_alpha((90.0 * hover) as u8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if response.has_focus() {
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(2.0, theme::accent()),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A play mark, always faintly there and full under the pointer.
|
||||||
|
let centre = rect.center();
|
||||||
|
painter.circle_filled(
|
||||||
|
centre,
|
||||||
|
18.0,
|
||||||
|
egui::Color32::from_black_alpha((110.0 + 80.0 * hover) as u8),
|
||||||
|
);
|
||||||
|
let ink = egui::Color32::WHITE.gamma_multiply(0.75 + 0.25 * hover);
|
||||||
|
let r = 7.0;
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
vec![
|
||||||
|
centre + egui::vec2(-r * 0.6, -r),
|
||||||
|
centre + egui::vec2(r, 0.0),
|
||||||
|
centre + egui::vec2(-r * 0.6, r),
|
||||||
|
],
|
||||||
|
ink,
|
||||||
|
egui::Stroke::NONE,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(progress) = episode.progress() {
|
||||||
|
let track = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left(), rect.bottom() - 4.0),
|
||||||
|
rect.right_bottom(),
|
||||||
|
);
|
||||||
|
let painter = painter.with_clip_rect(track);
|
||||||
|
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
|
||||||
|
let mut played = rect;
|
||||||
|
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
|
||||||
|
theme::accent_fill(&painter, played, radius, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if response
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text(if episode.resume_at().is_some() {
|
||||||
|
"Resume"
|
||||||
|
} else {
|
||||||
|
"Play"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One button that is the file's whole offline story: download it, see it
|
||||||
|
/// coming, pause it, or delete the copy.
|
||||||
|
fn download(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
title: &Title,
|
||||||
|
episode: &Episode,
|
||||||
|
offline: &OfflineView<'_>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
use ui::widgets::{DownloadGlyph, download_button};
|
||||||
|
|
||||||
|
let key = key_of(episode);
|
||||||
|
let current = offline.downloads.iter().find(|item| item.key == key);
|
||||||
|
if offline.files.contains(&key) {
|
||||||
|
if download_button(ui, DownloadGlyph::Done)
|
||||||
|
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match current.map(|item| (&item.state, item.percent())) {
|
||||||
|
Some((DownloadState::Running | DownloadState::Queued, fraction)) => {
|
||||||
|
if download_button(ui, DownloadGlyph::Progress(fraction))
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Downloading, {:.0}%. Click to pause.",
|
||||||
|
fraction * 100.0
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::PauseDownload(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
DownloadState::Paused | DownloadState::Cancelled | DownloadState::Failed(_),
|
||||||
|
fraction,
|
||||||
|
)) => {
|
||||||
|
if download_button(ui, DownloadGlyph::Paused(fraction))
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Stopped at {:.0}%. Click to carry on.",
|
||||||
|
fraction * 100.0
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ResumeDownload(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((DownloadState::Completed, _)) => {
|
||||||
|
download_button(ui, DownloadGlyph::Done).on_hover_text("Available offline");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if download_button(ui, DownloadGlyph::Download)
|
||||||
|
.on_hover_text("Download, to play without a connection")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
|
||||||
|
title, episode,
|
||||||
|
)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key_of(episode: &Episode) -> DownloadKey {
|
fn key_of(episode: &Episode) -> DownloadKey {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//! Short messages in the corner, for a few seconds.
|
||||||
|
//!
|
||||||
|
//! They replaced a single status line along the bottom of the window, which had
|
||||||
|
//! three problems a stack does not: a second message wiped the first before it
|
||||||
|
//! could be read, an error looked like any other line in a slightly different
|
||||||
|
//! grey, and the line took a strip of the page with it every time it appeared.
|
||||||
|
|
||||||
|
use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||||
|
|
||||||
|
use crate::app::Action;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/// How long a message stays up.
|
||||||
|
const SECONDS: f64 = 5.0;
|
||||||
|
/// How long an error stays up. Longer: it is more likely to be read twice.
|
||||||
|
const ERROR_SECONDS: f64 = 9.0;
|
||||||
|
/// How many fit at once. A burst beyond that drops the oldest.
|
||||||
|
const MOST: usize = 4;
|
||||||
|
/// How long one takes to arrive and to leave.
|
||||||
|
const FADE: f64 = theme::motion::FADE as f64;
|
||||||
|
const WIDTH: f32 = 340.0;
|
||||||
|
|
||||||
|
pub struct Toast {
|
||||||
|
id: u64,
|
||||||
|
text: String,
|
||||||
|
error: bool,
|
||||||
|
at: f64,
|
||||||
|
/// A button on the toast — "Undo" — and what it does.
|
||||||
|
action: Option<(&'static str, Action)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages on screen.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Toasts {
|
||||||
|
items: Vec<Toast>,
|
||||||
|
next: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Toasts {
|
||||||
|
pub fn push(&mut self, now: f64, text: impl Into<String>, error: bool) {
|
||||||
|
self.push_inner(now, text.into(), error, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message with a button on it, which runs `action` when pressed.
|
||||||
|
pub fn push_with(
|
||||||
|
&mut self,
|
||||||
|
now: f64,
|
||||||
|
text: impl Into<String>,
|
||||||
|
label: &'static str,
|
||||||
|
action: Action,
|
||||||
|
) {
|
||||||
|
self.push_inner(now, text.into(), false, Some((label, action)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_inner(
|
||||||
|
&mut self,
|
||||||
|
now: f64,
|
||||||
|
text: String,
|
||||||
|
error: bool,
|
||||||
|
action: Option<(&'static str, Action)>,
|
||||||
|
) {
|
||||||
|
// The same message again — a retry that failed the same way — restarts
|
||||||
|
// the one already up rather than stacking a copy of it.
|
||||||
|
if let Some(existing) = self.items.iter_mut().find(|toast| toast.text == text) {
|
||||||
|
existing.at = now;
|
||||||
|
existing.error = error;
|
||||||
|
existing.action = action;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.next += 1;
|
||||||
|
self.items.push(Toast {
|
||||||
|
id: self.next,
|
||||||
|
text,
|
||||||
|
error,
|
||||||
|
at: now,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
if self.items.len() > MOST {
|
||||||
|
self.items.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the stack above `bottom` points from the window's bottom edge, and
|
||||||
|
/// drop whatever has expired or been closed. Returns the action of a
|
||||||
|
/// toast whose button was pressed.
|
||||||
|
pub fn show(&mut self, ctx: &egui::Context, bottom: f32) -> Option<Action> {
|
||||||
|
let now = ctx.input(|input| input.time);
|
||||||
|
let pointer = ctx.input(|input| input.pointer.hover_pos());
|
||||||
|
let mut closed = None;
|
||||||
|
let mut pressed = None;
|
||||||
|
let mut hovered = false;
|
||||||
|
let mut y = ctx.content_rect().bottom() - bottom - 16.0;
|
||||||
|
let right = ctx.content_rect().right() - 16.0;
|
||||||
|
|
||||||
|
// Newest at the bottom, nearest where the eye already is.
|
||||||
|
for toast in self.items.iter().rev() {
|
||||||
|
let life = lifetime(toast.error);
|
||||||
|
let age = now - toast.at;
|
||||||
|
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
|
||||||
|
let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32;
|
||||||
|
let opacity = theme::motion::ease_out(arriving.min(leaving));
|
||||||
|
|
||||||
|
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.pivot(Align2::RIGHT_BOTTOM)
|
||||||
|
.fixed_pos(egui::pos2(right + (1.0 - opacity) * 24.0, y))
|
||||||
|
.interactable(true)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_opacity(opacity);
|
||||||
|
card(ui, toast)
|
||||||
|
});
|
||||||
|
match area.inner {
|
||||||
|
Pressed::Close => closed = Some(toast.id),
|
||||||
|
Pressed::Action => pressed = Some(toast.id),
|
||||||
|
Pressed::Nothing => {}
|
||||||
|
}
|
||||||
|
if pointer.is_some_and(|at| area.response.rect.contains(at)) {
|
||||||
|
hovered = true;
|
||||||
|
}
|
||||||
|
y -= area.response.rect.height() + 8.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = closed {
|
||||||
|
self.items.retain(|toast| toast.id != id);
|
||||||
|
}
|
||||||
|
// Pressed once, and gone: an "Undo" left up after it ran is an
|
||||||
|
// invitation to undo the undo, which it does not do.
|
||||||
|
let action = pressed.and_then(|id| {
|
||||||
|
let index = self.items.iter().position(|toast| toast.id == id)?;
|
||||||
|
self.items.remove(index).action.map(|(_, action)| action)
|
||||||
|
});
|
||||||
|
// Under the pointer, nothing leaves: someone is reading it.
|
||||||
|
if hovered {
|
||||||
|
for toast in &mut self.items {
|
||||||
|
let life = lifetime(toast.error);
|
||||||
|
toast.at = toast.at.max(now - life + 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.items
|
||||||
|
.retain(|toast| now - toast.at < lifetime(toast.error));
|
||||||
|
if !self.items.is_empty() {
|
||||||
|
// Enough frames to animate the edges and to notice an expiry.
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifetime(error: bool) -> f64 {
|
||||||
|
if error { ERROR_SECONDS } else { SECONDS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of a toast's buttons was pressed.
|
||||||
|
enum Pressed {
|
||||||
|
Nothing,
|
||||||
|
Close,
|
||||||
|
Action,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One message. Returns which of its buttons was pressed.
|
||||||
|
fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
||||||
|
let mut pressed = Pressed::Nothing;
|
||||||
|
let edge = if toast.error {
|
||||||
|
theme::danger()
|
||||||
|
} else {
|
||||||
|
theme::accent()
|
||||||
|
};
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.stroke(Stroke::new(1.0, theme::card_hover()))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin {
|
||||||
|
left: 16,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10,
|
||||||
|
})
|
||||||
|
.shadow(egui::Shadow {
|
||||||
|
offset: [0, 6],
|
||||||
|
blur: 18,
|
||||||
|
spread: 0,
|
||||||
|
color: Color32::from_black_alpha(90),
|
||||||
|
})
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(WIDTH);
|
||||||
|
let frame = ui.max_rect();
|
||||||
|
// The buttons first, from the right, so the text wraps in what
|
||||||
|
// they leave rather than pushing them off the card.
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::click());
|
||||||
|
let ink = if response.hovered() {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
};
|
||||||
|
let d = 4.0;
|
||||||
|
let c = rect.center();
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
if response.on_hover_text("Dismiss").clicked() {
|
||||||
|
pressed = Pressed::Close;
|
||||||
|
}
|
||||||
|
if let Some((label, _)) = &toast.action {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Label
|
||||||
|
.rich(*label)
|
||||||
|
.strong()
|
||||||
|
.color(theme::accent()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
pressed = Pressed::Action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(theme::Role::Label.rich(&toast.text).color(theme::text()))
|
||||||
|
.wrap(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// A strip down the leading edge says which kind it is before a word
|
||||||
|
// of it is read.
|
||||||
|
let strip = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
|
||||||
|
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
|
||||||
|
);
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge);
|
||||||
|
});
|
||||||
|
pressed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_message_restarts_rather_than_stacks() {
|
||||||
|
let mut toasts = Toasts::default();
|
||||||
|
toasts.push(0.0, "could not open", true);
|
||||||
|
toasts.push(3.0, "could not open", true);
|
||||||
|
assert_eq!(toasts.items.len(), 1);
|
||||||
|
assert_eq!(toasts.items[0].at, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_burst_keeps_only_the_newest() {
|
||||||
|
let mut toasts = Toasts::default();
|
||||||
|
for index in 0..10 {
|
||||||
|
toasts.push(0.0, format!("message {index}"), false);
|
||||||
|
}
|
||||||
|
assert_eq!(toasts.items.len(), MOST);
|
||||||
|
assert_eq!(toasts.items.last().unwrap().text, "message 9");
|
||||||
|
}
|
||||||
|
}
|
||||||
+256
-105
@@ -56,7 +56,7 @@ pub fn full(
|
|||||||
) {
|
) {
|
||||||
seek_bar(ui, playback, actions, 6.0);
|
seek_bar(ui, playback, actions, 6.0);
|
||||||
times(ui, playback, true);
|
times(ui, playback, true);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.set_min_height(ROW_HEIGHT_FULL);
|
ui.set_min_height(ROW_HEIGHT_FULL);
|
||||||
@@ -65,7 +65,7 @@ pub fn full(
|
|||||||
// hand reaches for it.
|
// hand reaches for it.
|
||||||
step_button(
|
step_button(
|
||||||
ui,
|
ui,
|
||||||
"⏮",
|
egui_phosphor::regular::SKIP_BACK,
|
||||||
"Previous (P)",
|
"Previous (P)",
|
||||||
neighbours.previous,
|
neighbours.previous,
|
||||||
actions,
|
actions,
|
||||||
@@ -76,14 +76,21 @@ pub fn full(
|
|||||||
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
|
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
|
||||||
play_pause(ui, playback, actions, 44.0);
|
play_pause(ui, playback, actions, 44.0);
|
||||||
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
|
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
|
||||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
step_button(
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
ui,
|
||||||
});
|
egui_phosphor::regular::SKIP_FORWARD,
|
||||||
|
"Next (N)",
|
||||||
|
neighbours.next,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
if playback.seeking || !playback.loaded {
|
if playback.seeking || playback.buffering || !playback.loaded {
|
||||||
ui.add(egui::Spinner::new().size(16.0));
|
ui.add(egui::Spinner::new().size(16.0));
|
||||||
ui.label(ui::muted(if playback.loaded {
|
ui.label(ui::muted(if playback.loaded && playback.seeking {
|
||||||
"seeking"
|
"seeking"
|
||||||
} else {
|
} else {
|
||||||
"buffering"
|
"buffering"
|
||||||
@@ -93,95 +100,153 @@ pub fn full(
|
|||||||
// Right cluster: everything that is a choice rather than a move.
|
// Right cluster: everything that is a choice rather than a move.
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
track_menus(ui, playback, actions);
|
track_menus(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
|
speed_menu(ui, playback, actions);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
chapter_menu(ui, playback, actions);
|
chapter_menu(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
volume(ui, playback, actions);
|
volume(ui, playback, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip under the library: what is playing, and back to it.
|
/// The strip under the library: what is playing, and back to it.
|
||||||
|
/// How big the live picture in the mini-player is drawn.
|
||||||
|
pub const MINI_PICTURE: Vec2 = Vec2::new(112.0, 63.0);
|
||||||
|
|
||||||
pub fn mini(
|
pub fn mini(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
|
picture: Option<egui::TextureId>,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.set_min_height(ROW_HEIGHT_MINI);
|
// The way back to the picture, first, because leaving the player page
|
||||||
|
// with Esc is otherwise a one-way trip: playback carries on with
|
||||||
// The way back to the picture. First, and a real button rather than a
|
// nowhere to watch it. The picture itself when there is one — it is
|
||||||
// label, because leaving the player page with Esc is otherwise a
|
// both the reminder that something is playing and the button.
|
||||||
// one-way trip: playback carries on with nowhere to watch it.
|
match picture {
|
||||||
if ui
|
Some(texture) => {
|
||||||
.add(
|
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
|
||||||
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
|
let painter = ui.painter();
|
||||||
.fill(theme::card_hover())
|
let radius = CornerRadius::same(theme::radius::SM);
|
||||||
.corner_radius(CornerRadius::same(8)),
|
painter.rect_filled(rect, radius, Color32::BLACK);
|
||||||
)
|
painter.add(
|
||||||
.on_hover_text("Show the picture again")
|
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
|
||||||
.clicked()
|
texture,
|
||||||
{
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
actions.push(Action::Goto(Page::Player));
|
),
|
||||||
}
|
);
|
||||||
ui.add_space(10.0);
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
ui.vertical(|ui| {
|
response.hovered(),
|
||||||
// Back to what is playing: after ten minutes of browsing, the bar
|
theme::motion::HOVER,
|
||||||
// is the only thing that still knows which title this came from.
|
);
|
||||||
if ui
|
if hover > 0.0 {
|
||||||
.add(
|
painter.rect_filled(
|
||||||
egui::Label::new(
|
rect,
|
||||||
egui::RichText::new(&playback.target.title_name)
|
radius,
|
||||||
.size(14.0)
|
Color32::from_black_alpha((110.0 * hover) as u8),
|
||||||
.strong(),
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
"Expand",
|
||||||
|
theme::Role::Caption.font(),
|
||||||
|
Color32::WHITE.gamma_multiply(hover),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if response.on_hover_text("Show the picture again").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Player));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
||||||
|
.fill(theme::card_hover())
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD)),
|
||||||
)
|
)
|
||||||
.sense(Sense::click()),
|
.on_hover_text("Show the picture again")
|
||||||
)
|
.clicked()
|
||||||
.on_hover_text("Show this title")
|
{
|
||||||
.clicked()
|
actions.push(Action::Goto(Page::Player));
|
||||||
{
|
}
|
||||||
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
|
|
||||||
}
|
}
|
||||||
ui.label(ui::muted(playback.target.caption()));
|
}
|
||||||
});
|
ui.add_space(theme::space::L);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.set_min_height(ROW_HEIGHT_MINI);
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.vertical(|ui| {
|
||||||
if ui.button("Stop").clicked() {
|
// Back to what is playing: after ten minutes of browsing, the bar
|
||||||
actions.push(Action::Player(Command::Stop));
|
// is the only thing that still knows which title this came from.
|
||||||
}
|
if ui
|
||||||
ui.add_space(6.0);
|
.add(
|
||||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
egui::Label::new(
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
theme::Role::Body.rich(&playback.target.title_name).strong(),
|
||||||
|
)
|
||||||
|
.sense(Sense::click()),
|
||||||
|
)
|
||||||
|
.on_hover_text("Show this title")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(playback.target.caption()));
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Stop").clicked() {
|
||||||
|
actions.push(Action::Player(Command::Stop));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
step_button(
|
||||||
|
ui,
|
||||||
|
egui_phosphor::regular::SKIP_FORWARD,
|
||||||
|
"Next (N)",
|
||||||
|
neighbours.next,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
play_pause(ui, playback, actions, 34.0);
|
||||||
|
step_button(
|
||||||
|
ui,
|
||||||
|
egui_phosphor::regular::SKIP_BACK,
|
||||||
|
"Previous (P)",
|
||||||
|
neighbours.previous,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Previous));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
track_menus(ui, playback, actions);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
volume(ui, playback, actions);
|
||||||
|
|
||||||
|
if playback.seeking || playback.buffering || !playback.loaded {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
play_pause(ui, playback, actions, 34.0);
|
ui.add_space(theme::space::XS);
|
||||||
step_button(
|
seek_bar(ui, playback, actions, 5.0);
|
||||||
ui,
|
times(ui, playback, false);
|
||||||
"⏮",
|
|
||||||
"Previous (P)",
|
|
||||||
neighbours.previous,
|
|
||||||
actions,
|
|
||||||
|a| {
|
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Previous));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
track_menus(ui, playback, actions);
|
|
||||||
ui.add_space(6.0);
|
|
||||||
volume(ui, playback, actions);
|
|
||||||
|
|
||||||
if playback.seeking || !playback.loaded {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(4.0);
|
|
||||||
seek_bar(ui, playback, actions, 5.0);
|
|
||||||
times(ui, playback, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one button whose position should never move.
|
/// The one button whose position should never move.
|
||||||
@@ -287,7 +352,7 @@ fn step_button(
|
|||||||
) {
|
) {
|
||||||
let response = ui.add_enabled(
|
let response = ui.add_enabled(
|
||||||
enabled,
|
enabled,
|
||||||
egui::Button::new(egui::RichText::new(label).size(15.0)).min_size(Vec2::new(38.0, 30.0)),
|
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
||||||
);
|
);
|
||||||
if response.on_hover_text(hover).clicked() {
|
if response.on_hover_text(hover).clicked() {
|
||||||
push(actions);
|
push(actions);
|
||||||
@@ -363,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let icon = if playback.muted || playback.volume <= 0.0 {
|
let icon = if playback.muted || playback.volume <= 0.0 {
|
||||||
"🔇"
|
egui_phosphor::regular::SPEAKER_SLASH
|
||||||
|
} else if playback.volume < 50.0 {
|
||||||
|
egui_phosphor::regular::SPEAKER_LOW
|
||||||
} else {
|
} else {
|
||||||
"🔊"
|
egui_phosphor::regular::SPEAKER_HIGH
|
||||||
};
|
};
|
||||||
if ui
|
if ui
|
||||||
.button(icon)
|
.button(theme::Role::Subhead.rich(icon))
|
||||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -433,6 +500,32 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How fast it plays. Says so on the button only when it is not normal speed,
|
||||||
|
/// because that is the one case worth noticing at a glance.
|
||||||
|
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
|
let label = if (playback.speed - 1.0).abs() < 1e-6 {
|
||||||
|
"Speed".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("Speed · {}", ui::format_speed(playback.speed))
|
||||||
|
};
|
||||||
|
ui.menu_button(label, |ui| {
|
||||||
|
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
|
||||||
|
if ui
|
||||||
|
.selectable_label(
|
||||||
|
(playback.speed - speed).abs() < 1e-6,
|
||||||
|
ui::format_speed(speed),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetSpeed(speed));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
.on_hover_text("Playback speed ([ and ])");
|
||||||
|
}
|
||||||
|
|
||||||
/// Jump to a chapter. Only drawn for a file that has more than one, which for
|
/// Jump to a chapter. Only drawn for a file that has more than one, which for
|
||||||
/// most releases means anime and not much else.
|
/// most releases means anime and not much else.
|
||||||
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
@@ -474,66 +567,101 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||||
|
|
||||||
let hovered = response.hovered() || response.dragged();
|
let active = response.hovered() || response.dragged();
|
||||||
let track = Rect::from_center_size(
|
// Grows rather than jumps when the pointer arrives.
|
||||||
rect.center(),
|
let grow =
|
||||||
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
|
ui.ctx()
|
||||||
);
|
.animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER);
|
||||||
|
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
|
||||||
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52));
|
// From the palette rather than a fixed grey: the same bar sits on the
|
||||||
|
// video's dark scrim and on a light theme's transport strip.
|
||||||
|
painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35));
|
||||||
|
|
||||||
if let Some(progress) = playback.progress() {
|
let duration = playback.duration.filter(|duration| *duration > 0.0);
|
||||||
|
// What is already read ahead, a shade lighter than the empty track: on a
|
||||||
|
// link this slow, whether a jump lands in it is the difference between an
|
||||||
|
// instant seek and a fetch.
|
||||||
|
if let (Some(duration), Some(end)) = (duration, playback.buffered)
|
||||||
|
&& end > playback.position
|
||||||
|
{
|
||||||
|
let from = (playback.position / duration).clamp(0.0, 1.0) as f32;
|
||||||
|
let to = (end / duration).clamp(0.0, 1.0) as f32;
|
||||||
|
let buffered = Rect::from_min_max(
|
||||||
|
egui::pos2(track.left() + track.width() * from, track.top()),
|
||||||
|
egui::pos2(track.left() + track.width() * to, track.bottom()),
|
||||||
|
);
|
||||||
|
painter.rect_filled(buffered, radius, theme::muted().gamma_multiply(0.30));
|
||||||
|
}
|
||||||
|
let pointer = response
|
||||||
|
.interact_pointer_pos()
|
||||||
|
.or_else(|| response.hover_pos())
|
||||||
|
.map(|position| ((position.x - track.left()) / track.width()).clamp(0.0, 1.0));
|
||||||
|
|
||||||
|
// Where the pointer would land, lighter than what has been played: the bar
|
||||||
|
// shows the jump before it is made.
|
||||||
|
if let (Some(fraction), true) = (pointer, active) {
|
||||||
|
let mut ghost = track;
|
||||||
|
ghost.set_width(track.width() * fraction);
|
||||||
|
painter.rect_filled(ghost, radius, theme::muted().gamma_multiply(0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
// While dragging, the knob follows the pointer even though the seek waits
|
||||||
|
// for the release — see below. A knob that stays put under a moving hand
|
||||||
|
// reads as a bar that is not listening.
|
||||||
|
let shown = match (response.dragged(), pointer) {
|
||||||
|
(true, Some(fraction)) => Some(fraction),
|
||||||
|
_ => playback.progress(),
|
||||||
|
};
|
||||||
|
if let Some(progress) = shown {
|
||||||
let mut played = track;
|
let mut played = track;
|
||||||
played.set_width(track.width() * progress);
|
played.set_width(track.width() * progress);
|
||||||
// The gradient is sized to the *played* part, not to the whole bar, so
|
// The gradient is sized to the *played* part, not to the whole bar, so
|
||||||
// the far end of it arrives at the playhead rather than at the end of
|
// the far end of it arrives at the playhead rather than at the end of
|
||||||
// the file. A bar that only ever shows the first fifth of its own ramp
|
// the file. A bar that only ever shows the first fifth of its own ramp
|
||||||
// does not read as a gradient at all.
|
// does not read as a gradient at all.
|
||||||
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
|
theme::accent_fill(painter, played, radius, 0.0);
|
||||||
painter.circle_filled(
|
painter.circle_filled(
|
||||||
egui::pos2(played.right(), track.center().y),
|
egui::pos2(played.right(), track.center().y),
|
||||||
if hovered { 8.0 } else { 6.0 },
|
5.0 + 3.0 * grow,
|
||||||
Color32::WHITE,
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seeking needs a duration: without one there is nothing for a fraction of
|
// Seeking needs a duration: without one there is nothing for a fraction of
|
||||||
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
|
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
|
||||||
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else {
|
let Some(duration) = duration else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chapter marks, over the bar rather than under it: an opening is two
|
// Chapter marks, cut into the bar rather than drawn on it: an opening is
|
||||||
// minutes of a twenty-four minute file, and the point of the mark is to be
|
// two minutes of a twenty-four minute file, and the point of the mark is to
|
||||||
// able to aim just past it.
|
// be able to aim just past it.
|
||||||
for chapter in playback.chapters.iter().skip(1) {
|
for chapter in playback.chapters.iter().skip(1) {
|
||||||
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
|
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
|
||||||
let x = track.left() + track.width() * fraction;
|
let x = track.left() + track.width() * fraction;
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
Rect::from_min_size(
|
Rect::from_min_size(
|
||||||
egui::pos2(x - 1.0, track.top() - 1.0),
|
egui::pos2(x - 1.5, track.top() - 1.0),
|
||||||
Vec2::new(2.0, track.height() + 2.0),
|
Vec2::new(3.0, track.height() + 2.0),
|
||||||
),
|
),
|
||||||
CornerRadius::ZERO,
|
CornerRadius::ZERO,
|
||||||
Color32::from_black_alpha(180),
|
Color32::from_black_alpha(200),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On release, not on every frame of the drag: each seek cancels outstanding
|
// On release, not on every frame of the drag: each seek cancels outstanding
|
||||||
// read-ahead and refetches, so a dragged bar that seeks continuously spends
|
// read-ahead and refetches, so a dragged bar that seeks continuously spends
|
||||||
// the whole drag throwing away blocks it just paid for.
|
// the whole drag throwing away blocks it just paid for.
|
||||||
if let Some(position) = response.interact_pointer_pos()
|
if let Some(fraction) = pointer
|
||||||
&& (response.clicked() || response.drag_stopped())
|
&& (response.clicked() || response.drag_stopped())
|
||||||
{
|
{
|
||||||
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
|
|
||||||
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
|
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.hovered()
|
if let (Some(fraction), true) = (pointer, active) {
|
||||||
&& let Some(position) = response.hover_pos()
|
let at = f64::from(fraction) * duration;
|
||||||
{
|
|
||||||
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
|
|
||||||
let at = fraction * duration;
|
|
||||||
// The chapter under the pointer, when there is one: "42:10 · OP" tells
|
// The chapter under the pointer, when there is one: "42:10 · OP" tells
|
||||||
// a viewer what they are about to land in, which is the whole reason to
|
// a viewer what they are about to land in, which is the whole reason to
|
||||||
// aim at a particular part of the bar.
|
// aim at a particular part of the bar.
|
||||||
@@ -545,6 +673,29 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
),
|
),
|
||||||
None => ui::format_time(at),
|
None => ui::format_time(at),
|
||||||
};
|
};
|
||||||
response.clone().on_hover_text_at_pointer(hint);
|
bubble(
|
||||||
|
ui,
|
||||||
|
egui::pos2(track.left() + track.width() * fraction, track.top() - 10.0),
|
||||||
|
&hint,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small label floating above a point, kept inside the window.
|
||||||
|
fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
|
||||||
|
let painter = ui.ctx().layer_painter(egui::LayerId::new(
|
||||||
|
egui::Order::Tooltip,
|
||||||
|
egui::Id::new("seek-bubble"),
|
||||||
|
));
|
||||||
|
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Label.font(), Color32::WHITE);
|
||||||
|
let size = galley.size() + Vec2::new(16.0, 8.0);
|
||||||
|
let screen = ui.ctx().content_rect();
|
||||||
|
let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0);
|
||||||
|
let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size);
|
||||||
|
painter.rect_filled(
|
||||||
|
frame,
|
||||||
|
CornerRadius::same(theme::radius::SM),
|
||||||
|
Color32::from_black_alpha(215),
|
||||||
|
);
|
||||||
|
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//! Controls egui does not have, or has in a shape that does not fit.
|
||||||
|
//!
|
||||||
|
//! Each is painted by hand for the same reason [`super::accent_button`] is: an
|
||||||
|
//! egui widget takes one flat colour, and what makes these readable is either a
|
||||||
|
//! gradient or a state drawn as a shape rather than as a fill.
|
||||||
|
|
||||||
|
use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/// An on/off switch with its label beside it, for settings.
|
||||||
|
///
|
||||||
|
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
||||||
|
/// on a card, which is where every setting lives, an unchecked box was not
|
||||||
|
/// there at all. A switch says "off" with a shape, not with a fill that has to
|
||||||
|
/// stand out from what is behind it.
|
||||||
|
pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
||||||
|
const TRACK: Vec2 = Vec2::new(34.0, 20.0);
|
||||||
|
|
||||||
|
let galley = ui.painter().layout_no_wrap(
|
||||||
|
text.to_owned(),
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
Color32::PLACEHOLDER,
|
||||||
|
);
|
||||||
|
let gap = if text.is_empty() { 0.0 } else { 10.0 };
|
||||||
|
let size = Vec2::new(
|
||||||
|
TRACK.x + gap + galley.size().x,
|
||||||
|
TRACK
|
||||||
|
.y
|
||||||
|
.max(galley.size().y)
|
||||||
|
.max(ui.spacing().interact_size.y),
|
||||||
|
);
|
||||||
|
let (rect, mut response) = ui.allocate_exact_size(size, Sense::click());
|
||||||
|
if response.clicked() {
|
||||||
|
*on = !*on;
|
||||||
|
response.mark_changed();
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *on, text));
|
||||||
|
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let ctx = ui.ctx();
|
||||||
|
let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE);
|
||||||
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
let track = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
|
||||||
|
TRACK,
|
||||||
|
);
|
||||||
|
let radius = CornerRadius::same((TRACK.y / 2.0) as u8);
|
||||||
|
let painter = ui.painter();
|
||||||
|
|
||||||
|
// Off is an outlined track; on is the accent filling it. Both are drawn
|
||||||
|
// through the switch's travel, so the fill arrives with the knob.
|
||||||
|
painter.rect_filled(track, radius, theme::card_hover());
|
||||||
|
painter.rect_stroke(
|
||||||
|
track,
|
||||||
|
radius,
|
||||||
|
Stroke::new(1.0, theme::muted().gamma_multiply(0.6 + 0.4 * hover)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
if t > 0.0 {
|
||||||
|
let mut lit = track;
|
||||||
|
lit.set_width(TRACK.y + (TRACK.x - TRACK.y) * t);
|
||||||
|
theme::accent_fill(painter, lit, radius, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let knob = egui::lerp(
|
||||||
|
(track.left() + TRACK.y / 2.0)..=(track.right() - TRACK.y / 2.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
let ink = theme::muted().lerp_to_gamma(Color32::WHITE, t);
|
||||||
|
painter.circle_filled(egui::pos2(knob, track.center().y), TRACK.y / 2.0 - 3.0, ink);
|
||||||
|
|
||||||
|
if response.has_focus() {
|
||||||
|
painter.rect_stroke(
|
||||||
|
track.expand(2.0),
|
||||||
|
CornerRadius::same((TRACK.y / 2.0 + 2.0) as u8),
|
||||||
|
Stroke::new(1.5, theme::accent()),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = egui::pos2(track.right() + gap, rect.center().y - galley.size().y / 2.0);
|
||||||
|
painter.galley(position, galley, theme::text());
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round mark that is either a filled tick or an empty ring: "watched".
|
||||||
|
///
|
||||||
|
/// The checkbox it replaced had the same invisible-when-off problem as the one
|
||||||
|
/// [`toggle`] replaced, and a list of episodes is the one place where the
|
||||||
|
/// unwatched state is the one that most needs to be seen.
|
||||||
|
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
||||||
|
const SIZE: f32 = 20.0;
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let ctx = ui.ctx();
|
||||||
|
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||||
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let center = rect.center();
|
||||||
|
let radius = SIZE / 2.0 - 1.0;
|
||||||
|
|
||||||
|
if t > 0.0 {
|
||||||
|
painter.circle_filled(center, radius, theme::accent().gamma_multiply(t));
|
||||||
|
}
|
||||||
|
painter.circle_stroke(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
Stroke::new(
|
||||||
|
1.5,
|
||||||
|
theme::muted()
|
||||||
|
.lerp_to_gamma(theme::text(), hover)
|
||||||
|
.gamma_multiply(1.0 - t),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The tick is drawn in both states: faintly, under the pointer, while
|
||||||
|
// unwatched — so the mark says what clicking it will do.
|
||||||
|
let ink = if watched {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
theme::muted().gamma_multiply(hover)
|
||||||
|
};
|
||||||
|
if watched || hover > 0.0 {
|
||||||
|
let points = [
|
||||||
|
center + Vec2::new(-4.5, 0.2),
|
||||||
|
center + Vec2::new(-1.4, 3.3),
|
||||||
|
center + Vec2::new(4.6, -3.2),
|
||||||
|
];
|
||||||
|
painter.line(points.to_vec(), Stroke::new(1.8, ink));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of settings: a heading, an optional line under it, and a card that
|
||||||
|
/// holds the rows.
|
||||||
|
///
|
||||||
|
/// The libadwaita preferences pattern, which is what `proton-drive-linux`
|
||||||
|
/// settles on: one card per subject, one row per setting, the setting's name
|
||||||
|
/// and what it does on the left and the control on the right — so the eye runs
|
||||||
|
/// down one edge for names and the other for state.
|
||||||
|
pub fn settings_group<R>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
heading: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
|
) -> R {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(heading)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
if let Some(description) = description {
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
ui.label(crate::ui::muted(description));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
let inner = egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(16, 6))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
add(ui)
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a [`settings_group`]: what it is on the left, the control on the
|
||||||
|
/// right. Rows after the first get a hairline above them.
|
||||||
|
pub fn settings_row<R>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
title: &str,
|
||||||
|
subtitle: Option<&str>,
|
||||||
|
control: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
|
) -> R {
|
||||||
|
let top = ui.cursor().top();
|
||||||
|
// The first row of a card starts at its top margin; anything below that
|
||||||
|
// has a row above it to be divided from.
|
||||||
|
if top > ui.max_rect().top() + 1.0 {
|
||||||
|
let y = top;
|
||||||
|
ui.painter().hline(
|
||||||
|
ui.max_rect().x_range(),
|
||||||
|
y,
|
||||||
|
Stroke::new(1.0, theme::card_hover()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
let inner = ui
|
||||||
|
.horizontal(|ui| {
|
||||||
|
ui.set_min_height(28.0);
|
||||||
|
let text_width = (ui.available_width() * 0.62).max(160.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_max_width(text_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = 2.0;
|
||||||
|
ui.label(theme::Role::Body.rich(title).color(theme::text()));
|
||||||
|
if let Some(subtitle) = subtitle {
|
||||||
|
ui.add(egui::Label::new(crate::ui::muted(subtitle)).wrap());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), control)
|
||||||
|
.inner
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of choices where exactly one is on: pills on a shared track.
|
||||||
|
///
|
||||||
|
/// Returns the one clicked, if it was not already the chosen one.
|
||||||
|
pub fn segmented<T: Copy + PartialEq>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
current: T,
|
||||||
|
choices: &[(T, &str)],
|
||||||
|
) -> Option<T> {
|
||||||
|
let mut picked = None;
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card_hover())
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(3))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (value, label) in choices {
|
||||||
|
let selected = *value == current;
|
||||||
|
let text = theme::Role::Label.rich(*label).color(if selected {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
theme::text()
|
||||||
|
});
|
||||||
|
let button = egui::Button::new(text)
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::SM))
|
||||||
|
.fill(if selected {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
})
|
||||||
|
.stroke(Stroke::NONE);
|
||||||
|
if ui.add(button).clicked() && !selected {
|
||||||
|
picked = Some(*value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
picked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a [`download_button`] shows.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum DownloadGlyph {
|
||||||
|
/// Not downloaded: an arrow into a tray.
|
||||||
|
Download,
|
||||||
|
/// Coming down: a ring filling with how far it has got.
|
||||||
|
Progress(f32),
|
||||||
|
/// Stopped part way: the ring, still, with a pause mark.
|
||||||
|
Paused(f32),
|
||||||
|
/// Here: a tick.
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round icon button for one file's download.
|
||||||
|
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
||||||
|
const SIZE: f32 = 30.0;
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
let hover =
|
||||||
|
ui.ctx()
|
||||||
|
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let c = rect.center();
|
||||||
|
if hover > 0.0 {
|
||||||
|
painter.circle_filled(c, SIZE / 2.0, theme::card_hover().gamma_multiply(hover));
|
||||||
|
}
|
||||||
|
let ink = theme::muted().lerp_to_gamma(theme::text(), hover);
|
||||||
|
let stroke = Stroke::new(1.6, ink);
|
||||||
|
let ring = |painter: &egui::Painter, fraction: f32| {
|
||||||
|
let radius = 9.0;
|
||||||
|
painter.circle_stroke(c, radius, Stroke::new(2.0, theme::card_hover()));
|
||||||
|
let steps = 40;
|
||||||
|
let points: Vec<egui::Pos2> = (0..=((steps as f32 * fraction.clamp(0.0, 1.0)) as usize))
|
||||||
|
.map(|step| {
|
||||||
|
let angle = -std::f32::consts::FRAC_PI_2
|
||||||
|
+ std::f32::consts::TAU * step as f32 / steps as f32;
|
||||||
|
c + Vec2::angled(angle) * radius
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if points.len() > 1 {
|
||||||
|
painter.add(egui::Shape::line(points, Stroke::new(2.0, theme::accent())));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match glyph {
|
||||||
|
DownloadGlyph::Download => {
|
||||||
|
painter.line_segment([c + Vec2::new(0.0, -6.0), c + Vec2::new(0.0, 3.0)], stroke);
|
||||||
|
painter.line(
|
||||||
|
vec![
|
||||||
|
c + Vec2::new(-4.0, -1.0),
|
||||||
|
c + Vec2::new(0.0, 3.0),
|
||||||
|
c + Vec2::new(4.0, -1.0),
|
||||||
|
],
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
painter.line_segment([c + Vec2::new(-6.0, 7.0), c + Vec2::new(6.0, 7.0)], stroke);
|
||||||
|
}
|
||||||
|
DownloadGlyph::Progress(fraction) => {
|
||||||
|
ring(painter, fraction);
|
||||||
|
// Keeps a frame coming while the ring has somewhere to go.
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(250));
|
||||||
|
if hover > 0.0 {
|
||||||
|
for x in [-2.5, 2.5] {
|
||||||
|
painter.line_segment(
|
||||||
|
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
|
||||||
|
Stroke::new(1.8, ink.gamma_multiply(hover)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadGlyph::Paused(fraction) => {
|
||||||
|
ring(painter, fraction);
|
||||||
|
for x in [-2.5, 2.5] {
|
||||||
|
painter.line_segment(
|
||||||
|
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
|
||||||
|
Stroke::new(1.8, ink),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadGlyph::Done => {
|
||||||
|
painter.line(
|
||||||
|
vec![
|
||||||
|
c + Vec2::new(-5.0, 0.0),
|
||||||
|
c + Vec2::new(-1.5, 3.5),
|
||||||
|
c + Vec2::new(5.0, -3.5),
|
||||||
|
],
|
||||||
|
Stroke::new(2.0, theme::accent().lerp_to_gamma(ink, hover)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small rounded label: a genre, a year, a fact.
|
||||||
|
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card_hover().gamma_multiply(0.8))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
@@ -67,8 +67,10 @@ pub struct VideoSurface {
|
|||||||
impl VideoSurface {
|
impl VideoSurface {
|
||||||
/// Attach a renderer to `player` on the current OpenGL context.
|
/// Attach a renderer to `player` on the current OpenGL context.
|
||||||
///
|
///
|
||||||
/// `ctx` is woken whenever mpv has a new frame; without that the picture
|
/// `wake` is called whenever mpv has a new frame, and should ask the window
|
||||||
/// would only advance when the viewer moved the mouse.
|
/// for one. Without it the picture would only advance when the viewer moved
|
||||||
|
/// the mouse. The app's goes through [`crate::engine::Engine::wake`], which
|
||||||
|
/// knows whether the picture is on screen to be drawn at all.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
///
|
///
|
||||||
@@ -78,11 +80,11 @@ impl VideoSurface {
|
|||||||
pub unsafe fn new(
|
pub unsafe fn new(
|
||||||
player: Arc<Player>,
|
player: Arc<Player>,
|
||||||
gl: Arc<glow::Context>,
|
gl: Arc<glow::Context>,
|
||||||
ctx: egui::Context,
|
wake: impl Fn() + Send + Sync + 'static,
|
||||||
) -> pstr_player::Result<Self> {
|
) -> pstr_player::Result<Self> {
|
||||||
// SAFETY: forwarded from this function's own contract.
|
// SAFETY: forwarded from this function's own contract.
|
||||||
let mut renderer = unsafe { VideoRenderer::new(player)? };
|
let mut renderer = unsafe { VideoRenderer::new(player)? };
|
||||||
renderer.on_update(move || ctx.request_repaint());
|
renderer.on_update(wake);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
gl,
|
gl,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! The window's size, kept between launches.
|
||||||
|
//!
|
||||||
|
//! Size and maximized only. Position is left to the window manager: Wayland
|
||||||
|
//! does not let a client choose it, and on X11 a remembered position on a
|
||||||
|
//! monitor that has since been unplugged opens the window off screen.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the next launch should open at.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WindowState {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest size worth restoring. Anything below is a window that was
|
||||||
|
/// being dragged about when it closed, or a file someone edited by hand.
|
||||||
|
const SMALLEST: [f32; 2] = [720.0, 480.0];
|
||||||
|
|
||||||
|
impl WindowState {
|
||||||
|
/// The saved state, if there is a sensible one. A missing or broken file
|
||||||
|
/// means the default size, never an error: this is a convenience.
|
||||||
|
pub fn load(path: &Path) -> Option<Self> {
|
||||||
|
let state: Self = pstr_core::config::read_json(path).ok().flatten()?;
|
||||||
|
state.sensible().then_some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sensible(&self) -> bool {
|
||||||
|
self.width.is_finite()
|
||||||
|
&& self.height.is_finite()
|
||||||
|
&& self.width >= SMALLEST[0]
|
||||||
|
&& self.height >= SMALLEST[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold this frame's viewport into `last`.
|
||||||
|
///
|
||||||
|
/// A fullscreen window says nothing about how big the window should be, and
|
||||||
|
/// a maximized one says nothing about how big it should be once it is not,
|
||||||
|
/// so neither changes the size kept.
|
||||||
|
pub fn observe(ctx: &egui::Context, last: Option<Self>) -> Option<Self> {
|
||||||
|
let (size, maximized, fullscreen) = ctx.input(|input| {
|
||||||
|
let viewport = input.viewport();
|
||||||
|
(
|
||||||
|
viewport.inner_rect.map(|rect| rect.size()),
|
||||||
|
viewport.maximized.unwrap_or(false),
|
||||||
|
viewport.fullscreen.unwrap_or(false),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if fullscreen {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
let (width, height) = match (maximized, last, size) {
|
||||||
|
(true, Some(last), _) => (last.width, last.height),
|
||||||
|
(_, _, Some(size)) => (size.x, size.y),
|
||||||
|
_ => return last,
|
||||||
|
};
|
||||||
|
let state = Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maximized,
|
||||||
|
};
|
||||||
|
state.sensible().then_some(state).or(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the state when it changes, once, at exit.
|
||||||
|
pub struct WindowMemory {
|
||||||
|
path: PathBuf,
|
||||||
|
saved: Option<WindowState>,
|
||||||
|
pub current: Option<WindowState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowMemory {
|
||||||
|
pub fn new(path: PathBuf) -> Self {
|
||||||
|
let saved = WindowState::load(&path);
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
saved,
|
||||||
|
current: saved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn observe(&mut self, ctx: &egui::Context) {
|
||||||
|
self.current = WindowState::observe(ctx, self.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let Some(current) = self.current.filter(|current| Some(*current) != self.saved) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = pstr_core::config::write_json(&self.path, ¤t) {
|
||||||
|
tracing::debug!("save the window size: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_smaller_than_the_minimum_is_not_restored() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pstr-window-{}.json", std::process::id()));
|
||||||
|
let tiny = WindowState {
|
||||||
|
width: 200.0,
|
||||||
|
height: 100.0,
|
||||||
|
maximized: false,
|
||||||
|
};
|
||||||
|
pstr_core::config::write_json(&path, &tiny).unwrap();
|
||||||
|
assert_eq!(WindowState::load(&path), None);
|
||||||
|
|
||||||
|
let normal = WindowState {
|
||||||
|
width: 1400.0,
|
||||||
|
height: 900.0,
|
||||||
|
maximized: true,
|
||||||
|
};
|
||||||
|
pstr_core::config::write_json(&path, &normal).unwrap();
|
||||||
|
assert_eq!(WindowState::load(&path), Some(normal));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
use crate::config::{AppDirs, read_json, write_json};
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
/// A palette family: Catppuccin's four, plus the one this app shipped with.
|
/// A palette family: Catppuccin's four, the one this app shipped with, and one
|
||||||
|
/// borrowed from a menu screen.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum Flavor {
|
pub enum Flavor {
|
||||||
@@ -22,15 +23,18 @@ pub enum Flavor {
|
|||||||
Frappe,
|
Frappe,
|
||||||
Macchiato,
|
Macchiato,
|
||||||
Mocha,
|
Mocha,
|
||||||
|
/// Black, white and one loud red — the Persona 5 menus.
|
||||||
|
Persona5,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Flavor {
|
impl Flavor {
|
||||||
pub const ALL: [Self; 5] = [
|
pub const ALL: [Self; 6] = [
|
||||||
Self::Proton,
|
Self::Proton,
|
||||||
Self::Mocha,
|
Self::Mocha,
|
||||||
Self::Macchiato,
|
Self::Macchiato,
|
||||||
Self::Frappe,
|
Self::Frappe,
|
||||||
Self::Latte,
|
Self::Latte,
|
||||||
|
Self::Persona5,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
@@ -40,6 +44,7 @@ impl Flavor {
|
|||||||
Self::Frappe => "Catppuccin Frappé",
|
Self::Frappe => "Catppuccin Frappé",
|
||||||
Self::Macchiato => "Catppuccin Macchiato",
|
Self::Macchiato => "Catppuccin Macchiato",
|
||||||
Self::Mocha => "Catppuccin Mocha",
|
Self::Mocha => "Catppuccin Mocha",
|
||||||
|
Self::Persona5 => "Persona 5",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +55,7 @@ impl Flavor {
|
|||||||
Self::Frappe => "Dark, warm, the lowest contrast of the three",
|
Self::Frappe => "Dark, warm, the lowest contrast of the three",
|
||||||
Self::Macchiato => "Dark, between Frappé and Mocha",
|
Self::Macchiato => "Dark, between Frappé and Mocha",
|
||||||
Self::Mocha => "Dark, the deepest of Catppuccin's three",
|
Self::Mocha => "Dark, the deepest of Catppuccin's three",
|
||||||
|
Self::Persona5 => "Black and white with one loud red — pick the Red accent for it",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +88,15 @@ pub enum Accent {
|
|||||||
Blue,
|
Blue,
|
||||||
Teal,
|
Teal,
|
||||||
Peach,
|
Peach,
|
||||||
|
/// The flavour's warning colour, used on purpose. Every flavour has one
|
||||||
|
/// because everything here needs a colour for a failure, and in
|
||||||
|
/// [`Flavor::Persona5`] it is the point of the palette rather than an
|
||||||
|
/// afterthought.
|
||||||
|
Red,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Accent {
|
impl Accent {
|
||||||
pub const ALL: [Self; 8] = [
|
pub const ALL: [Self; 9] = [
|
||||||
Self::Mauve,
|
Self::Mauve,
|
||||||
Self::Pink,
|
Self::Pink,
|
||||||
Self::Sky,
|
Self::Sky,
|
||||||
@@ -94,6 +105,7 @@ impl Accent {
|
|||||||
Self::Blue,
|
Self::Blue,
|
||||||
Self::Teal,
|
Self::Teal,
|
||||||
Self::Peach,
|
Self::Peach,
|
||||||
|
Self::Red,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
@@ -106,6 +118,7 @@ impl Accent {
|
|||||||
Self::Blue => "Blue",
|
Self::Blue => "Blue",
|
||||||
Self::Teal => "Teal",
|
Self::Teal => "Teal",
|
||||||
Self::Peach => "Peach",
|
Self::Peach => "Peach",
|
||||||
|
Self::Red => "Red",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +164,427 @@ pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
|
|||||||
write_json(&appearance_file(dirs), appearance)
|
write_json(&appearance_file(dirs), appearance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One colour, 8 bits a channel, with no alpha.
|
||||||
|
///
|
||||||
|
/// Deliberately not a front end's colour type: this module resolves a choice
|
||||||
|
/// into colours for *both* clients, and neither egui's `Color32` nor Compose's
|
||||||
|
/// `Color` may appear here.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Rgb {
|
||||||
|
pub r: u8,
|
||||||
|
pub g: u8,
|
||||||
|
pub b: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rgb {
|
||||||
|
pub const BLACK: Self = Self::new(0, 0, 0);
|
||||||
|
|
||||||
|
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||||
|
Self { r, g, b }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packed as `0xAARRGGBB`, opaque — the form both Android and a hex literal
|
||||||
|
/// want.
|
||||||
|
pub const fn argb(self) -> u32 {
|
||||||
|
0xff00_0000 | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One channel from sRGB into linear light, as the sRGB transfer function
|
||||||
|
/// defines it. Blending has to happen here: mixing sRGB values directly darkens
|
||||||
|
/// the middle of a ramp between saturated hues, which is exactly where a
|
||||||
|
/// gradient is looked at.
|
||||||
|
fn linear(channel: u8) -> f32 {
|
||||||
|
let value = channel as f32 / 255.0;
|
||||||
|
if value <= 0.04045 {
|
||||||
|
value / 12.92
|
||||||
|
} else {
|
||||||
|
((value + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back out of linear light, rounded to a channel.
|
||||||
|
fn gamma(value: f32) -> u8 {
|
||||||
|
let value = value.clamp(0.0, 1.0);
|
||||||
|
let encoded = if value <= 0.003_130_8 {
|
||||||
|
value * 12.92
|
||||||
|
} else {
|
||||||
|
1.055 * value.powf(1.0 / 2.4) - 0.055
|
||||||
|
};
|
||||||
|
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blend two colours in linear light.
|
||||||
|
pub fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb {
|
||||||
|
let blend = |from: u8, to: u8| gamma(linear(from) * (1.0 - t) + linear(to) * t);
|
||||||
|
Rgb::new(
|
||||||
|
blend(from.r, to.r),
|
||||||
|
blend(from.g, to.g),
|
||||||
|
blend(from.b, to.b),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative luminance, in linear light, as WCAG defines it.
|
||||||
|
pub fn luminance(color: Rgb) -> f32 {
|
||||||
|
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
||||||
|
/// 21.0 for black against white.
|
||||||
|
fn contrast(one: Rgb, other: Rgb) -> f32 {
|
||||||
|
let (one, other) = (luminance(one), luminance(other));
|
||||||
|
(one.max(other) + 0.05) / (one.min(other) + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The least contrast a label gets anywhere along a gradient between two
|
||||||
|
/// colours. Sampled rather than solved: the worst point is not always an end,
|
||||||
|
/// because the ink can be brighter than one end and darker than the other.
|
||||||
|
pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
|
||||||
|
(0..=4)
|
||||||
|
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The least contrast the app will accept between a label and the fill under
|
||||||
|
/// it: WCAG AA for large text, which is what wears the accent — button labels,
|
||||||
|
/// a tab, a play glyph, never body copy.
|
||||||
|
pub const MIN_CONTRAST: f32 = 3.0;
|
||||||
|
|
||||||
|
/// Every colour a client draws with, resolved from a [`Flavor`] and an
|
||||||
|
/// [`Accent`].
|
||||||
|
///
|
||||||
|
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
|
||||||
|
/// a drawing loop has to think about.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Palette {
|
||||||
|
/// Behind everything: the page.
|
||||||
|
pub background: Rgb,
|
||||||
|
/// The bars above and below it.
|
||||||
|
pub surface: Rgb,
|
||||||
|
/// The deepest colour in the flavour — text fields, wells.
|
||||||
|
pub sunken: Rgb,
|
||||||
|
/// Tiles, rows, forms.
|
||||||
|
pub card: Rgb,
|
||||||
|
pub card_hover: Rgb,
|
||||||
|
pub border: Rgb,
|
||||||
|
pub text: Rgb,
|
||||||
|
pub muted: Rgb,
|
||||||
|
/// The one strong colour.
|
||||||
|
pub accent: Rgb,
|
||||||
|
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
|
||||||
|
/// the accent is a single hue and gradients would have nothing to do.
|
||||||
|
pub accent_alt: Rgb,
|
||||||
|
/// The accent taken down into the background, for a pressed control and for
|
||||||
|
/// selection behind text.
|
||||||
|
pub accent_dim: Rgb,
|
||||||
|
/// Ink that stays readable on top of the accent. Chosen by the accent's
|
||||||
|
/// luminance, because Catppuccin's dark flavours have *pale* accents and
|
||||||
|
/// white-on-pastel is not readable at 13 px.
|
||||||
|
pub on_accent: Rgb,
|
||||||
|
pub danger: Rgb,
|
||||||
|
/// Whether this is a light theme.
|
||||||
|
pub light: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One flavour's raw colours, named as Catppuccin names them.
|
||||||
|
///
|
||||||
|
/// Stored rather than computed: these are somebody else's palette, and the
|
||||||
|
/// point of using it is to use it exactly.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Ramp {
|
||||||
|
base: Rgb,
|
||||||
|
mantle: Rgb,
|
||||||
|
crust: Rgb,
|
||||||
|
surface0: Rgb,
|
||||||
|
surface1: Rgb,
|
||||||
|
subtext0: Rgb,
|
||||||
|
text: Rgb,
|
||||||
|
pink: Rgb,
|
||||||
|
mauve: Rgb,
|
||||||
|
sky: Rgb,
|
||||||
|
sapphire: Rgb,
|
||||||
|
blue: Rgb,
|
||||||
|
lavender: Rgb,
|
||||||
|
teal: Rgb,
|
||||||
|
green: Rgb,
|
||||||
|
peach: Rgb,
|
||||||
|
yellow: Rgb,
|
||||||
|
red: Rgb,
|
||||||
|
light: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn rgb(hex: u32) -> Rgb {
|
||||||
|
Rgb::new(
|
||||||
|
((hex >> 16) & 0xff) as u8,
|
||||||
|
((hex >> 8) & 0xff) as u8,
|
||||||
|
(hex & 0xff) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The palette this app shipped with, extended to the hues an accent can be.
|
||||||
|
///
|
||||||
|
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
|
||||||
|
/// chosen to sit on a near-black base at roughly the saturation that one does,
|
||||||
|
/// which is a good deal hotter than any Catppuccin flavour.
|
||||||
|
const PROTON: Ramp = Ramp {
|
||||||
|
base: rgb(0x0e0e12),
|
||||||
|
mantle: rgb(0x17171d),
|
||||||
|
crust: rgb(0x0a0a0d),
|
||||||
|
surface0: rgb(0x1e1e26),
|
||||||
|
surface1: rgb(0x2a2a35),
|
||||||
|
subtext0: rgb(0x8e8e9c),
|
||||||
|
text: rgb(0xeaeaf0),
|
||||||
|
pink: rgb(0xff5fbe),
|
||||||
|
mauve: rgb(0x7d4dff),
|
||||||
|
sky: rgb(0x4dd2ff),
|
||||||
|
sapphire: rgb(0x3a8fc4),
|
||||||
|
blue: rgb(0x3f6fe0),
|
||||||
|
lavender: rgb(0xa68cff),
|
||||||
|
teal: rgb(0x3fd6b8),
|
||||||
|
green: rgb(0x56d364),
|
||||||
|
peach: rgb(0xff9a4d),
|
||||||
|
yellow: rgb(0xffd166),
|
||||||
|
red: rgb(0xe05561),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LATTE: Ramp = Ramp {
|
||||||
|
base: rgb(0xeff1f5),
|
||||||
|
mantle: rgb(0xe6e9ef),
|
||||||
|
crust: rgb(0xdce0e8),
|
||||||
|
surface0: rgb(0xccd0da),
|
||||||
|
surface1: rgb(0xbcc0cc),
|
||||||
|
subtext0: rgb(0x6c6f85),
|
||||||
|
text: rgb(0x4c4f69),
|
||||||
|
pink: rgb(0xea76cb),
|
||||||
|
mauve: rgb(0x8839ef),
|
||||||
|
sky: rgb(0x04a5e5),
|
||||||
|
sapphire: rgb(0x209fb5),
|
||||||
|
blue: rgb(0x1e66f5),
|
||||||
|
lavender: rgb(0x7287fd),
|
||||||
|
teal: rgb(0x179299),
|
||||||
|
green: rgb(0x40a02b),
|
||||||
|
peach: rgb(0xfe640b),
|
||||||
|
yellow: rgb(0xdf8e1d),
|
||||||
|
red: rgb(0xd20f39),
|
||||||
|
light: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRAPPE: Ramp = Ramp {
|
||||||
|
base: rgb(0x303446),
|
||||||
|
mantle: rgb(0x292c3c),
|
||||||
|
crust: rgb(0x232634),
|
||||||
|
surface0: rgb(0x414559),
|
||||||
|
surface1: rgb(0x51576d),
|
||||||
|
subtext0: rgb(0xa5adce),
|
||||||
|
text: rgb(0xc6d0f5),
|
||||||
|
pink: rgb(0xf4b8e4),
|
||||||
|
mauve: rgb(0xca9ee6),
|
||||||
|
sky: rgb(0x99d1db),
|
||||||
|
sapphire: rgb(0x85c1dc),
|
||||||
|
blue: rgb(0x8caaee),
|
||||||
|
lavender: rgb(0xbabbf1),
|
||||||
|
teal: rgb(0x81c8be),
|
||||||
|
green: rgb(0xa6d189),
|
||||||
|
peach: rgb(0xef9f76),
|
||||||
|
yellow: rgb(0xe5c890),
|
||||||
|
red: rgb(0xe78284),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MACCHIATO: Ramp = Ramp {
|
||||||
|
base: rgb(0x24273a),
|
||||||
|
mantle: rgb(0x1e2030),
|
||||||
|
crust: rgb(0x181926),
|
||||||
|
surface0: rgb(0x363a4f),
|
||||||
|
surface1: rgb(0x494d64),
|
||||||
|
subtext0: rgb(0xa5adcb),
|
||||||
|
text: rgb(0xcad3f5),
|
||||||
|
pink: rgb(0xf5bde6),
|
||||||
|
mauve: rgb(0xc6a0f6),
|
||||||
|
sky: rgb(0x91d7e3),
|
||||||
|
sapphire: rgb(0x7dc4e4),
|
||||||
|
blue: rgb(0x8aadf4),
|
||||||
|
lavender: rgb(0xb7bdf8),
|
||||||
|
teal: rgb(0x8bd5ca),
|
||||||
|
green: rgb(0xa6da95),
|
||||||
|
peach: rgb(0xf5a97f),
|
||||||
|
yellow: rgb(0xeed49f),
|
||||||
|
red: rgb(0xed8796),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCHA: Ramp = Ramp {
|
||||||
|
base: rgb(0x1e1e2e),
|
||||||
|
mantle: rgb(0x181825),
|
||||||
|
crust: rgb(0x11111b),
|
||||||
|
surface0: rgb(0x313244),
|
||||||
|
surface1: rgb(0x45475a),
|
||||||
|
subtext0: rgb(0xa6adc8),
|
||||||
|
text: rgb(0xcdd6f4),
|
||||||
|
pink: rgb(0xf5c2e7),
|
||||||
|
mauve: rgb(0xcba6f7),
|
||||||
|
sky: rgb(0x89dceb),
|
||||||
|
sapphire: rgb(0x74c7ec),
|
||||||
|
blue: rgb(0x89b4fa),
|
||||||
|
lavender: rgb(0xb4befe),
|
||||||
|
teal: rgb(0x94e2d5),
|
||||||
|
green: rgb(0xa6e3a1),
|
||||||
|
peach: rgb(0xfab387),
|
||||||
|
yellow: rgb(0xf9e2af),
|
||||||
|
red: rgb(0xf38ba8),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Persona 5's menus: black, white, and a red that is not asking permission.
|
||||||
|
///
|
||||||
|
/// The three colours that matter are `crust`/`base` (black), `text` (white) and
|
||||||
|
/// `red` — everything between them is that same red bled into the black, which
|
||||||
|
/// is what keeps a card from reading as grey furniture in a palette that has no
|
||||||
|
/// grey in it. The other hues exist because an accent can be any of them; they
|
||||||
|
/// are pushed to full saturation to sit on a black page, which is the one thing
|
||||||
|
/// this palette has in common with [`PROTON`].
|
||||||
|
const PERSONA5: Ramp = Ramp {
|
||||||
|
base: rgb(0x0a0708),
|
||||||
|
mantle: rgb(0x140d0f),
|
||||||
|
crust: rgb(0x000000),
|
||||||
|
surface0: rgb(0x1d1215),
|
||||||
|
surface1: rgb(0x33191d),
|
||||||
|
subtext0: rgb(0xb59a9d),
|
||||||
|
text: rgb(0xf5f2f2),
|
||||||
|
pink: rgb(0xff2e63),
|
||||||
|
mauve: rgb(0xb13bd6),
|
||||||
|
sky: rgb(0x2ec7ff),
|
||||||
|
sapphire: rgb(0x1f9bd0),
|
||||||
|
blue: rgb(0x2f5ee0),
|
||||||
|
lavender: rgb(0xa07bff),
|
||||||
|
teal: rgb(0x12c2a0),
|
||||||
|
green: rgb(0x2fbf4f),
|
||||||
|
peach: rgb(0xff7a1a),
|
||||||
|
yellow: rgb(0xffc400),
|
||||||
|
red: rgb(0xe60012),
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Ramp {
|
||||||
|
const fn of(flavor: Flavor) -> Self {
|
||||||
|
match flavor {
|
||||||
|
Flavor::Proton => PROTON,
|
||||||
|
Flavor::Latte => LATTE,
|
||||||
|
Flavor::Frappe => FRAPPE,
|
||||||
|
Flavor::Macchiato => MACCHIATO,
|
||||||
|
Flavor::Mocha => MOCHA,
|
||||||
|
Flavor::Persona5 => PERSONA5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accent, and the hue a gradient in it runs into.
|
||||||
|
///
|
||||||
|
/// The partners are neighbours on the wheel rather than complements: a
|
||||||
|
/// gradient across half the spectrum passes through a colour that belongs
|
||||||
|
/// to neither end, and on a seek bar that reads as a bug. The exception is
|
||||||
|
/// [`Accent::PinkSky`], which is the whole point of that entry.
|
||||||
|
const fn accents(&self, accent: Accent) -> (Rgb, Rgb) {
|
||||||
|
match accent {
|
||||||
|
Accent::Mauve => (self.mauve, self.blue),
|
||||||
|
Accent::Pink => (self.pink, self.mauve),
|
||||||
|
Accent::Sky => (self.sky, self.sapphire),
|
||||||
|
Accent::PinkSky => (self.pink, self.sky),
|
||||||
|
Accent::Lavender => (self.lavender, self.blue),
|
||||||
|
Accent::Blue => (self.blue, self.sapphire),
|
||||||
|
Accent::Teal => (self.teal, self.green),
|
||||||
|
Accent::Peach => (self.peach, self.yellow),
|
||||||
|
Accent::Red => (self.red, self.pink),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
/// The default, spelled out as a constant so the global has something to
|
||||||
|
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
|
||||||
|
/// panic message painted before the engine exists, still gets colours.
|
||||||
|
pub const PROTON: Self = Self {
|
||||||
|
background: PROTON.base,
|
||||||
|
surface: PROTON.mantle,
|
||||||
|
sunken: PROTON.crust,
|
||||||
|
card: PROTON.surface0,
|
||||||
|
card_hover: PROTON.surface1,
|
||||||
|
border: rgb(0x262630),
|
||||||
|
text: PROTON.text,
|
||||||
|
muted: PROTON.subtext0,
|
||||||
|
accent: PROTON.mauve,
|
||||||
|
accent_alt: PROTON.blue,
|
||||||
|
accent_dim: rgb(0x5333ad),
|
||||||
|
on_accent: PROTON.text,
|
||||||
|
danger: PROTON.red,
|
||||||
|
light: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve a choice into colours.
|
||||||
|
pub fn resolve(appearance: Appearance) -> Self {
|
||||||
|
let ramp = Ramp::of(appearance.flavor);
|
||||||
|
let (accent, partner) = ramp.accents(appearance.accent);
|
||||||
|
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
|
||||||
|
// at body-text weight — which makes them mid-luminance, and a
|
||||||
|
// mid-luminance fill is one that neither black nor white sits on. They
|
||||||
|
// are taken down in value here, and only here: the hue and the
|
||||||
|
// saturation are Latte's, so it still looks like Latte, and a label on
|
||||||
|
// a button is legible.
|
||||||
|
let deepen = |color: Rgb| {
|
||||||
|
if ramp.light {
|
||||||
|
mix(color, Rgb::BLACK, 0.30)
|
||||||
|
} else {
|
||||||
|
color
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let accent = deepen(accent);
|
||||||
|
let accent_alt = if appearance.gradients {
|
||||||
|
deepen(partner)
|
||||||
|
} else {
|
||||||
|
accent
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ink that survives on the accent. The flavour's own reading first —
|
||||||
|
// pale text on a dark theme — and the opposite when that does not clear
|
||||||
|
// the bar, which on Catppuccin's dark flavours is most of the time:
|
||||||
|
// their accents are *pastel*, and white on #f5c2e7 is not a label.
|
||||||
|
//
|
||||||
|
// Latte's darkest colour is its body text, and even that is only #4c4f69,
|
||||||
|
// so the dark side is taken a third of the way to black.
|
||||||
|
let ink_dark = if ramp.light {
|
||||||
|
mix(ramp.text, Rgb::BLACK, 0.35)
|
||||||
|
} else {
|
||||||
|
ramp.crust
|
||||||
|
};
|
||||||
|
let ink_light = if ramp.light { ramp.base } else { ramp.text };
|
||||||
|
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
|
||||||
|
ink_light
|
||||||
|
} else {
|
||||||
|
ink_dark
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
background: ramp.base,
|
||||||
|
surface: ramp.mantle,
|
||||||
|
sunken: ramp.crust,
|
||||||
|
card: ramp.surface0,
|
||||||
|
card_hover: ramp.surface1,
|
||||||
|
border: ramp.surface1,
|
||||||
|
text: ramp.text,
|
||||||
|
muted: ramp.subtext0,
|
||||||
|
accent,
|
||||||
|
accent_alt,
|
||||||
|
// Behind text, so it is the accent taken most of the way back to
|
||||||
|
// the page: a selection in a saturated hue is a selection nobody
|
||||||
|
// can read through.
|
||||||
|
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
|
||||||
|
on_accent,
|
||||||
|
danger: ramp.red,
|
||||||
|
light: ramp.light,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -209,4 +643,84 @@ mod tests {
|
|||||||
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
|
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_flavour_and_accent_resolves_to_readable_ink() {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
for accent in Accent::ALL {
|
||||||
|
let palette = Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
accent,
|
||||||
|
gradients: true,
|
||||||
|
});
|
||||||
|
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
||||||
|
assert!(
|
||||||
|
ratio >= MIN_CONTRAST,
|
||||||
|
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_light_flavour_reports_itself_light() {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
let palette = Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
..Appearance::default()
|
||||||
|
});
|
||||||
|
assert_eq!(palette.light, flavor.is_light());
|
||||||
|
// And the page is on the right side of the middle either way.
|
||||||
|
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_5_is_black_with_that_red_in_it() {
|
||||||
|
let palette = Palette::resolve(Appearance {
|
||||||
|
flavor: Flavor::Persona5,
|
||||||
|
accent: Accent::Red,
|
||||||
|
gradients: false,
|
||||||
|
});
|
||||||
|
assert_eq!(palette.accent, PERSONA5.red);
|
||||||
|
assert!(!palette.light);
|
||||||
|
// Black enough that the artwork is the brightest thing on the page:
|
||||||
|
// darker than the palette this app shipped with, which is the other
|
||||||
|
// near-black one.
|
||||||
|
assert!(luminance(palette.background) < luminance(PROTON.base));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_flavour_can_wear_its_own_warning_colour() {
|
||||||
|
for flavor in Flavor::ALL {
|
||||||
|
let palette = Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
accent: Accent::Red,
|
||||||
|
gradients: true,
|
||||||
|
});
|
||||||
|
// The accent is the flavour's red — deepened on the light flavour,
|
||||||
|
// where it would otherwise be too bright to put a label on.
|
||||||
|
assert!(palette.accent.r > palette.accent.b, "{flavor:?}");
|
||||||
|
assert_ne!(palette.accent, palette.background, "{flavor:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turning_gradients_off_leaves_one_colour_to_draw() {
|
||||||
|
let flat = Palette::resolve(Appearance {
|
||||||
|
accent: Accent::PinkSky,
|
||||||
|
gradients: false,
|
||||||
|
..Appearance::default()
|
||||||
|
});
|
||||||
|
assert_eq!(flat.accent, flat.accent_alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blend_stays_between_the_two_colours_it_blends() {
|
||||||
|
let (from, to) = (Rgb::new(0, 0, 0), Rgb::new(255, 255, 255));
|
||||||
|
assert_eq!(mix(from, to, 0.0), from);
|
||||||
|
assert_eq!(mix(from, to, 1.0), to);
|
||||||
|
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
|
||||||
|
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+175
-27
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
|||||||
use crate::naming::{self, ParsedName};
|
use crate::naming::{self, ParsedName};
|
||||||
|
|
||||||
/// Bump when adding a migration.
|
/// Bump when adding a migration.
|
||||||
pub const SCHEMA_VERSION: i64 = 7;
|
pub const SCHEMA_VERSION: i64 = 8;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -146,6 +146,13 @@ CREATE TABLE title_track_prefs (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// A language cannot tell two English subtitle tracks apart — "Signs & Songs"
|
||||||
|
/// and "Full Subtitles" — so the track's title is remembered beside it.
|
||||||
|
const MIGRATION_V8: &str = r#"
|
||||||
|
ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT;
|
||||||
|
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
|
||||||
|
"#;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct OfflineFile {
|
pub struct OfflineFile {
|
||||||
pub revision_id: String,
|
pub revision_id: String,
|
||||||
@@ -157,6 +164,12 @@ pub struct TitleTrackPrefs {
|
|||||||
pub audio_language: Option<String>,
|
pub audio_language: Option<String>,
|
||||||
pub subtitle_language: Option<String>,
|
pub subtitle_language: Option<String>,
|
||||||
pub subtitles: bool,
|
pub subtitles: bool,
|
||||||
|
/// The name the muxer gave the chosen audio track, when it had one. What
|
||||||
|
/// picks between two tracks in the same language on the next episode.
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subtitle_title: Option<String>,
|
||||||
}
|
}
|
||||||
impl Default for TitleTrackPrefs {
|
impl Default for TitleTrackPrefs {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -164,10 +177,26 @@ impl Default for TitleTrackPrefs {
|
|||||||
audio_language: None,
|
audio_language: None,
|
||||||
subtitle_language: None,
|
subtitle_language: None,
|
||||||
subtitles: true,
|
subtitles: true,
|
||||||
|
audio_title: None,
|
||||||
|
subtitle_title: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `title_track_prefs` row, its columns starting at `first`.
|
||||||
|
fn title_track_prefs_row(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
first: usize,
|
||||||
|
) -> rusqlite::Result<TitleTrackPrefs> {
|
||||||
|
Ok(TitleTrackPrefs {
|
||||||
|
audio_language: row.get(first)?,
|
||||||
|
subtitle_language: row.get(first + 1)?,
|
||||||
|
subtitles: row.get::<_, i64>(first + 2)? != 0,
|
||||||
|
audio_title: row.get(first + 3)?,
|
||||||
|
subtitle_title: row.get(first + 4)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||||
const ABSOLUTE_SEASON: i64 = -1;
|
const ABSOLUTE_SEASON: i64 = -1;
|
||||||
|
|
||||||
@@ -227,6 +256,16 @@ impl Catalog {
|
|||||||
Ok(catalog)
|
Ok(catalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many rows this connection has written since it was opened.
|
||||||
|
///
|
||||||
|
/// A cache key, not a statistic: anything derived from the catalog stays
|
||||||
|
/// valid exactly as long as this does not move. SQLite counts it for us, so
|
||||||
|
/// no write path has to remember to invalidate anything — which is the
|
||||||
|
/// failure mode a hand-maintained counter has.
|
||||||
|
pub fn writes(&self) -> u64 {
|
||||||
|
self.conn.total_changes()
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate(&mut self) -> Result<()> {
|
fn migrate(&mut self) -> Result<()> {
|
||||||
let version: i64 = self
|
let version: i64 = self
|
||||||
.conn
|
.conn
|
||||||
@@ -287,6 +326,12 @@ impl Catalog {
|
|||||||
tx.pragma_update(None, "user_version", 7)?;
|
tx.pragma_update(None, "user_version", 7)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if version < 8 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V8)?;
|
||||||
|
tx.pragma_update(None, "user_version", 8)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -500,27 +545,43 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
||||||
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT audio_language, subtitle_language, subtitles, audio_title, subtitle_title \
|
||||||
|
FROM title_track_prefs WHERE title_key=?1",
|
||||||
|
params![title_key],
|
||||||
|
|row| title_track_prefs_row(row, 0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
||||||
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
|
self.conn.execute(
|
||||||
|
"INSERT INTO title_track_prefs \
|
||||||
|
(title_key,audio_language,subtitle_language,subtitles,audio_title,subtitle_title) \
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(title_key) DO UPDATE SET \
|
||||||
|
audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,\
|
||||||
|
subtitles=excluded.subtitles,audio_title=excluded.audio_title,\
|
||||||
|
subtitle_title=excluded.subtitle_title",
|
||||||
|
params![
|
||||||
|
title_key,
|
||||||
|
prefs.audio_language,
|
||||||
|
prefs.subtitle_language,
|
||||||
|
prefs.subtitles as i64,
|
||||||
|
prefs.audio_title,
|
||||||
|
prefs.subtitle_title
|
||||||
|
],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
|
"SELECT title_key, audio_language, subtitle_language, subtitles, audio_title, \
|
||||||
|
subtitle_title FROM title_track_prefs",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], |row| {
|
let rows =
|
||||||
Ok((
|
statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?;
|
||||||
row.get(0)?,
|
|
||||||
TitleTrackPrefs {
|
|
||||||
audio_language: row.get(1)?,
|
|
||||||
subtitle_language: row.get(2)?,
|
|
||||||
subtitles: row.get::<_, i64>(3)? != 0,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,7 +610,9 @@ impl Catalog {
|
|||||||
|
|
||||||
/// Record where playback is.
|
/// Record where playback is.
|
||||||
pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> {
|
pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> {
|
||||||
self.conn.execute(
|
// Cached: playback saves its position every few seconds, and this is
|
||||||
|
// the one write that runs on a clock rather than on a click.
|
||||||
|
let mut upsert = self.conn.prepare_cached(
|
||||||
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||||
@@ -557,15 +620,15 @@ impl Catalog {
|
|||||||
duration_secs = excluded.duration_secs,
|
duration_secs = excluded.duration_secs,
|
||||||
watched = excluded.watched,
|
watched = excluded.watched,
|
||||||
updated_at = excluded.updated_at",
|
updated_at = excluded.updated_at",
|
||||||
params![
|
|
||||||
share_id,
|
|
||||||
link_id,
|
|
||||||
state.position_secs,
|
|
||||||
state.duration_secs,
|
|
||||||
state.watched as i64,
|
|
||||||
state.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
|
upsert.execute(params![
|
||||||
|
share_id,
|
||||||
|
link_id,
|
||||||
|
state.position_secs,
|
||||||
|
state.duration_secs,
|
||||||
|
state.watched as i64,
|
||||||
|
state.updated_at,
|
||||||
|
])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,7 +1046,7 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
|||||||
// episode's name rather than the series'.
|
// episode's name rather than the series'.
|
||||||
if row.parsed.episode_title.is_none()
|
if row.parsed.episode_title.is_none()
|
||||||
&& !row.parsed.title.is_empty()
|
&& !row.parsed.title.is_empty()
|
||||||
&& row.parsed.title != title
|
&& !row.parsed.title.eq_ignore_ascii_case(&title)
|
||||||
{
|
{
|
||||||
let rest = strip_prefix_title(&row.parsed.title, &title);
|
let rest = strip_prefix_title(&row.parsed.title, &title);
|
||||||
// …unless what is left is a season marker. `Oshi no Ko 3rd
|
// …unless what is left is a season marker. `Oshi no Ko 3rd
|
||||||
@@ -992,7 +1055,11 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
|||||||
// that is worse than calling none of them anything.
|
// that is worse than calling none of them anything.
|
||||||
if naming::is_season_marker(&rest) {
|
if naming::is_season_marker(&rest) {
|
||||||
row.parsed.season = row.parsed.season.or(naming::parse(&rest).season);
|
row.parsed.season = row.parsed.season.or(naming::parse(&rest).season);
|
||||||
} else {
|
} else if !rest.eq_ignore_ascii_case(&title) {
|
||||||
|
// Nor when the strip found nothing to remove: a filename
|
||||||
|
// whose title only *differs* from the folder's — spacing,
|
||||||
|
// punctuation, a stray article — names the series, not the
|
||||||
|
// episode, and taking it hands every row the same text.
|
||||||
row.parsed.episode_title = Some(rest);
|
row.parsed.episode_title = Some(rest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1076,10 +1143,17 @@ fn ancestry(
|
|||||||
///
|
///
|
||||||
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
|
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
|
||||||
/// `Neon Genesis Evangelion` should read as `Death & Rebirth`.
|
/// `Neon Genesis Evangelion` should read as `Death & Rebirth`.
|
||||||
|
///
|
||||||
|
/// Case-insensitively, because the two names come from different places: the
|
||||||
|
/// folder is typed by whoever organised the share and the prefix is whatever a
|
||||||
|
/// release group capitalised. `[Anime Time] Attack On Titan - 38.mkv` under a
|
||||||
|
/// folder called `Attack on Titan` differs in exactly one letter, and a
|
||||||
|
/// case-sensitive strip leaves the whole series name behind as the episode's.
|
||||||
fn strip_prefix_title(episode: &str, series: &str) -> String {
|
fn strip_prefix_title(episode: &str, series: &str) -> String {
|
||||||
let stripped = episode
|
let stripped = episode
|
||||||
.strip_prefix(series)
|
.get(..series.len())
|
||||||
.map(|rest| rest.trim_start_matches([' ', '-', '_', '.', ':']))
|
.filter(|head| head.eq_ignore_ascii_case(series))
|
||||||
|
.map(|_| episode[series.len()..].trim_start_matches([' ', '-', '_', '.', ':']))
|
||||||
.unwrap_or(episode)
|
.unwrap_or(episode)
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
@@ -1370,6 +1444,37 @@ mod tests {
|
|||||||
assert_eq!(version, SCHEMA_VERSION);
|
assert_eq!(version, SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_titles_track_choice_round_trips_with_the_track_names() {
|
||||||
|
let catalog = Catalog::in_memory().expect("open");
|
||||||
|
assert_eq!(catalog.title_track_prefs("show").expect("read"), None);
|
||||||
|
|
||||||
|
let prefs = TitleTrackPrefs {
|
||||||
|
audio_language: Some("jpn".into()),
|
||||||
|
subtitle_language: Some("eng".into()),
|
||||||
|
subtitles: true,
|
||||||
|
audio_title: None,
|
||||||
|
subtitle_title: Some("Full Subtitles".into()),
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_title_track_prefs("show", &prefs)
|
||||||
|
.expect("write");
|
||||||
|
assert_eq!(
|
||||||
|
catalog.title_track_prefs("show").expect("read"),
|
||||||
|
Some(prefs.clone())
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = TitleTrackPrefs {
|
||||||
|
subtitle_title: Some("Signs & Songs".into()),
|
||||||
|
..prefs
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_title_track_prefs("show", &changed)
|
||||||
|
.expect("overwrite");
|
||||||
|
let all = catalog.all_title_track_prefs().expect("read all");
|
||||||
|
assert_eq!(all.get("show"), Some(&changed));
|
||||||
|
}
|
||||||
|
|
||||||
/// A newer schema is refused, not downgraded — reinterpreting it would
|
/// A newer schema is refused, not downgraded — reinterpreting it would
|
||||||
/// silently mangle whatever the newer build recorded.
|
/// silently mangle whatever the newer build recorded.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1518,6 +1623,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1632,6 +1739,47 @@ mod tests {
|
|||||||
assert_eq!(episode.parsed.episode_title, None);
|
assert_eq!(episode.parsed.episode_title, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **What every episode of Attack on Titan was called.** The folder and the
|
||||||
|
/// release group capitalised the same series two ways, and a case-sensitive
|
||||||
|
/// prefix strip left the whole series name behind — so all fifty-nine rows
|
||||||
|
/// read `Attack On Titan`, naming nothing and telling the viewer apart from
|
||||||
|
/// nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_series_name_cased_differently_is_not_the_episodes_name() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("aot", Some("root"), "Attack on Titan"),
|
||||||
|
video("e38", "aot", "[Anime Time] Attack On Titan - 38.mkv"),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
let episode = row(&rows, "e38");
|
||||||
|
|
||||||
|
assert_eq!(episode.parsed.title, "Attack on Titan", "the folder's");
|
||||||
|
assert_eq!(episode.parsed.episode, Some(38));
|
||||||
|
assert_eq!(episode.parsed.episode_title, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefix strip is case-insensitive for the same reason: what follows
|
||||||
|
/// the series name is the episode's, however the group capitalised it.
|
||||||
|
#[test]
|
||||||
|
fn a_prefix_cased_differently_is_still_stripped() {
|
||||||
|
let nodes = vec![
|
||||||
|
root(),
|
||||||
|
folder("eva", Some("root"), "Neon Genesis Evangelion"),
|
||||||
|
video(
|
||||||
|
"film",
|
||||||
|
"eva",
|
||||||
|
"NEON GENESIS EVANGELION - Death & Rebirth.mkv",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let rows = build_rows("share", &nodes);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
row(&rows, "film").parsed.episode_title.as_deref(),
|
||||||
|
Some("Death & Rebirth"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The deepest naming folder wins, so films inside a collection folder are
|
/// The deepest naming folder wins, so films inside a collection folder are
|
||||||
/// titled individually rather than by the collection.
|
/// titled individually rather than by the collection.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
//! The chapters a file was muxed with, and what they are for.
|
||||||
|
//!
|
||||||
|
//! Anime releases mark their opening and ending as chapters, which is the whole
|
||||||
|
//! reason this exists: with them a player can offer to skip the ninety seconds
|
||||||
|
//! everyone skips, and without them it cannot.
|
||||||
|
//!
|
||||||
|
//! Nothing here talks to a player. Reading the chapter list out of mpv is
|
||||||
|
//! `pstr-player`'s job on the desktop and the JNI adapter's on Android; both
|
||||||
|
//! hand the result to [`roles`], so a chapter means the same thing on either.
|
||||||
|
|
||||||
|
/// One chapter of the file.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Chapter {
|
||||||
|
/// Position in the list, which is what mpv's `chapter` property is set to.
|
||||||
|
pub index: i64,
|
||||||
|
/// The name the muxer gave it, if any.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Where it starts, in seconds.
|
||||||
|
pub start: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chapter {
|
||||||
|
/// What to show in a list. A nameless chapter is still worth an entry —
|
||||||
|
/// it is a place in the file — so it gets its number.
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
match &self.title {
|
||||||
|
Some(title) => title.clone(),
|
||||||
|
None => format!("Chapter {}", self.index + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this chapter is, from its name alone.
|
||||||
|
///
|
||||||
|
/// Prefer [`roles`], which resolves the names that only mean one thing *in
|
||||||
|
/// context* — a chapter called `Intro` is an opening in one release and the
|
||||||
|
/// first ten minutes of the story in another.
|
||||||
|
pub fn role(&self) -> ChapterRole {
|
||||||
|
match Claim::of(self.title.as_deref().unwrap_or_default()) {
|
||||||
|
Claim::Opening => ChapterRole::Opening,
|
||||||
|
Claim::Ending => ChapterRole::Ending,
|
||||||
|
Claim::Preview => ChapterRole::Preview,
|
||||||
|
// Unresolved without the rest of the file: assume it is content,
|
||||||
|
// which is the reading that never skips anything.
|
||||||
|
_ => ChapterRole::Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kinds of chapter worth treating differently from "part of the episode".
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ChapterRole {
|
||||||
|
/// The opening titles.
|
||||||
|
Opening,
|
||||||
|
/// The ending titles.
|
||||||
|
Ending,
|
||||||
|
/// A preview of the next episode, after the ending.
|
||||||
|
Preview,
|
||||||
|
/// Anything else, including everything unnamed.
|
||||||
|
Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChapterRole {
|
||||||
|
/// What a button offering to skip this should say. `None` for a chapter
|
||||||
|
/// nobody wants skipped.
|
||||||
|
pub fn skip_label(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::Opening => Some("Skip opening"),
|
||||||
|
Self::Ending => Some("Skip ending"),
|
||||||
|
Self::Preview => Some("Skip preview"),
|
||||||
|
Self::Content => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this is something the file could end on without the viewer
|
||||||
|
/// missing anything: credits and a trailer for next week.
|
||||||
|
///
|
||||||
|
/// An *opening* is not, even though it is skippable — a file does not end
|
||||||
|
/// with one, and treating it as tail would make a mis-tagged chapter
|
||||||
|
/// auto-advance out of the episode.
|
||||||
|
pub fn ends_the_episode(self) -> bool {
|
||||||
|
matches!(self, Self::Ending | Self::Preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a chapter's *name* claims, before the rest of the file is consulted.
|
||||||
|
///
|
||||||
|
/// The two `Maybe` variants are the whole reason this is separate from
|
||||||
|
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
|
||||||
|
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
|
||||||
|
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
|
||||||
|
/// files and a mid-episode scene in a few. Neither can be settled by the name.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Claim {
|
||||||
|
Opening,
|
||||||
|
Ending,
|
||||||
|
Preview,
|
||||||
|
/// Reads like an opening, but only if the file has no real one and it is
|
||||||
|
/// the length of a theme song.
|
||||||
|
MaybeOpening,
|
||||||
|
/// Reads like credits, but only near the end of the file or at theme
|
||||||
|
/// length.
|
||||||
|
MaybeEnding,
|
||||||
|
Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long an opening or ending theme runs.
|
||||||
|
///
|
||||||
|
/// Anime themes are ninety seconds by convention and rarely stray far. This is
|
||||||
|
/// the corroboration for a name that could go either way: a chapter called
|
||||||
|
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
|
||||||
|
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
|
||||||
|
|
||||||
|
/// Credits can run longer than a theme — a full cast roll over a still — so a
|
||||||
|
/// chapter that only *reads* like an ending gets this much room when it sits at
|
||||||
|
/// the end of the file.
|
||||||
|
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
|
||||||
|
|
||||||
|
/// An opening that far into the file is not an opening. Cold opens run long in
|
||||||
|
/// a first episode, so this is generous.
|
||||||
|
const OPENING_BEFORE_SECONDS: f64 = 480.0;
|
||||||
|
|
||||||
|
/// Where the last part of a file starts, as a fraction of it. A chapter that
|
||||||
|
/// merely reads like credits is taken as credits past this point.
|
||||||
|
const TAIL_FRACTION: f64 = 0.75;
|
||||||
|
|
||||||
|
impl Claim {
|
||||||
|
/// Read a claim out of a chapter name.
|
||||||
|
///
|
||||||
|
/// Matched on whole words from the front, not on substrings: `Operation
|
||||||
|
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
|
||||||
|
/// an ending, and skipping a viewer out of either is a mistake they cannot
|
||||||
|
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
|
||||||
|
/// Opening`) is stepped over first, because plenty of muxes carry it.
|
||||||
|
fn of(title: &str) -> Self {
|
||||||
|
// Japanese names appear verbatim in releases from JP sources, and they
|
||||||
|
// are unambiguous where the English ones are not. Checked before the
|
||||||
|
// word split, which only knows about ASCII and would find no words at
|
||||||
|
// all in a name written entirely in kana.
|
||||||
|
if title.contains("オープニング") {
|
||||||
|
return Self::Opening;
|
||||||
|
}
|
||||||
|
if title.contains("エンディング") {
|
||||||
|
return Self::Ending;
|
||||||
|
}
|
||||||
|
if title.contains("予告") {
|
||||||
|
return Self::Preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
let words = words(title);
|
||||||
|
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||||
|
let Some((head, rest)) = words.split_first() else {
|
||||||
|
return Self::Content;
|
||||||
|
};
|
||||||
|
let second = rest.first().copied().unwrap_or_default();
|
||||||
|
|
||||||
|
match (*head, second) {
|
||||||
|
// Two-word forms first: the second word is what tells `next
|
||||||
|
// episode` from `next`, and `end credits` from `end`.
|
||||||
|
("next", _) | ("nep", _) => Self::Preview,
|
||||||
|
("end" | "ending" | "closing", "credits") => Self::Ending,
|
||||||
|
("cast" | "staff", "roll" | "credits") => Self::Ending,
|
||||||
|
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
|
||||||
|
("ending" | "ed", "theme" | "song") => Self::Ending,
|
||||||
|
|
||||||
|
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
|
||||||
|
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
|
||||||
|
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
|
||||||
|
|
||||||
|
("intro", _) => Self::MaybeOpening,
|
||||||
|
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
|
||||||
|
_ => Self::Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chapter name as lowercase words, with any leading numbering dropped.
|
||||||
|
///
|
||||||
|
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
|
||||||
|
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
|
||||||
|
fn words(title: &str) -> Vec<String> {
|
||||||
|
let mut words: Vec<String> = title
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.map(|word| {
|
||||||
|
word.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// A leading token that was *only* digits is numbering, not a name.
|
||||||
|
if words.first().is_some_and(String::is_empty) {
|
||||||
|
words.remove(0);
|
||||||
|
}
|
||||||
|
words.retain(|word| !word.is_empty());
|
||||||
|
words
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The role of every chapter in a file, with the file's own shape taken into
|
||||||
|
/// account.
|
||||||
|
///
|
||||||
|
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
|
||||||
|
/// cannot tell an opening called `Intro` from ten minutes of story called the
|
||||||
|
/// same thing; here the other chapters and the running time settle it:
|
||||||
|
///
|
||||||
|
/// * a chapter that merely *reads* like an opening is one only when the file
|
||||||
|
/// has no chapter that says so outright, it runs the length of a theme song,
|
||||||
|
/// and it is near the front;
|
||||||
|
/// * a chapter that reads like credits is the ending when it runs to about that
|
||||||
|
/// length, or when it sits in the last quarter of the file.
|
||||||
|
///
|
||||||
|
/// Both rules fail *towards content*: not offering a skip costs a button, and
|
||||||
|
/// skipping the viewer out of the story costs them the story.
|
||||||
|
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
|
||||||
|
let claims: Vec<Claim> = chapters
|
||||||
|
.iter()
|
||||||
|
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
|
||||||
|
.collect();
|
||||||
|
let has_real_opening = claims.contains(&Claim::Opening);
|
||||||
|
|
||||||
|
claims
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, claim)| {
|
||||||
|
let length = chapter_end(chapters, index, duration)
|
||||||
|
.map(|end| end - chapters[index].start)
|
||||||
|
.filter(|length| *length > 0.0);
|
||||||
|
let start = chapters[index].start;
|
||||||
|
|
||||||
|
match claim {
|
||||||
|
Claim::Opening => ChapterRole::Opening,
|
||||||
|
Claim::Ending => ChapterRole::Ending,
|
||||||
|
Claim::Preview => ChapterRole::Preview,
|
||||||
|
Claim::MaybeOpening
|
||||||
|
if !has_real_opening
|
||||||
|
&& start <= OPENING_BEFORE_SECONDS
|
||||||
|
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
|
||||||
|
{
|
||||||
|
ChapterRole::Opening
|
||||||
|
}
|
||||||
|
Claim::MaybeEnding
|
||||||
|
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|
||||||
|
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
|
||||||
|
{
|
||||||
|
ChapterRole::Ending
|
||||||
|
}
|
||||||
|
_ => ChapterRole::Content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the run of chapters that ends the file — credits, a preview, and
|
||||||
|
/// nothing the viewer would miss — begins.
|
||||||
|
///
|
||||||
|
/// **The suffix, not the last chapter.** A release that puts a Part C after the
|
||||||
|
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
|
||||||
|
/// exactly once) must give up only the preview, or auto-advancing skips a scene
|
||||||
|
/// that matters. Walking back from the end and stopping at the first chapter
|
||||||
|
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
|
||||||
|
/// preview alone, while the ordinary `… ED · Preview` yields both.
|
||||||
|
///
|
||||||
|
/// `None` when the file ends on content, has no chapters, or is *entirely*
|
||||||
|
/// skippable — the last of which is a mis-tagged file, not an episode that is
|
||||||
|
/// all credits.
|
||||||
|
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
|
||||||
|
let first = roles
|
||||||
|
.iter()
|
||||||
|
.rposition(|role| !role.ends_the_episode())
|
||||||
|
.map(|last_content| last_content + 1)?;
|
||||||
|
if first >= chapters.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(chapters[first].start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the chapter starting at `index` ends: the next one's start, or the end
|
||||||
|
/// of the file.
|
||||||
|
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
|
||||||
|
chapters
|
||||||
|
.get(index + 1)
|
||||||
|
.map(|next| next.start)
|
||||||
|
.or(duration)
|
||||||
|
.filter(|end| *end > 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chapter a position falls in.
|
||||||
|
///
|
||||||
|
/// Linear rather than a binary search: a file has a dozen chapters, and this is
|
||||||
|
/// called once a frame at most.
|
||||||
|
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
|
||||||
|
chapters
|
||||||
|
.iter()
|
||||||
|
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
|
||||||
|
Chapter {
|
||||||
|
index,
|
||||||
|
title: Some(title.to_string()),
|
||||||
|
start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_names_releases_actually_use_are_recognised() {
|
||||||
|
for (name, claim) in [
|
||||||
|
("OP", Claim::Opening),
|
||||||
|
("Opening", Claim::Opening),
|
||||||
|
("OP1 - Again", Claim::Opening),
|
||||||
|
("02 - Opening", Claim::Opening),
|
||||||
|
("Opening Song", Claim::Opening),
|
||||||
|
("NCOP", Claim::Opening),
|
||||||
|
("オープニング", Claim::Opening),
|
||||||
|
("ED", Claim::Ending),
|
||||||
|
("Ending 2", Claim::Ending),
|
||||||
|
("End Credits", Claim::Ending),
|
||||||
|
("Cast Roll", Claim::Ending),
|
||||||
|
("Endcard", Claim::Ending),
|
||||||
|
("エンディング", Claim::Ending),
|
||||||
|
("Preview", Claim::Preview),
|
||||||
|
("Next Episode", Claim::Preview),
|
||||||
|
("Next Episode Preview", Claim::Preview),
|
||||||
|
("予告", Claim::Preview),
|
||||||
|
] {
|
||||||
|
assert_eq!(Claim::of(name), claim, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
|
||||||
|
// The reason this matches words and not prefixes: skipping the viewer
|
||||||
|
// out of "Operation Briefing" would be a bug they cannot undo without
|
||||||
|
// finding the seek bar.
|
||||||
|
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
|
||||||
|
assert_eq!(Claim::of(name), Claim::Content, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names that cannot be settled by reading them.
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_name_is_not_decided_by_itself() {
|
||||||
|
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
|
||||||
|
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
|
||||||
|
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
|
||||||
|
// And a chapter read in isolation never resolves to a skip.
|
||||||
|
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
|
||||||
|
/// minute episode that begins with a long `Intro` that is the story, and
|
||||||
|
/// ends with a cast scroll. Offering "skip opening" over the first ninety
|
||||||
|
/// seconds of *that* skips the beginning of the series.
|
||||||
|
#[test]
|
||||||
|
fn a_long_intro_is_the_episode_and_not_an_opening() {
|
||||||
|
let chapters = vec![
|
||||||
|
chapter(0, "Intro", 0.0),
|
||||||
|
chapter(1, "Part A", 700.0),
|
||||||
|
chapter(2, "Part B", 2400.0),
|
||||||
|
chapter(3, "Cast", 4680.0),
|
||||||
|
];
|
||||||
|
let roles = roles(&chapters, Some(4800.0));
|
||||||
|
assert_eq!(
|
||||||
|
roles[0],
|
||||||
|
ChapterRole::Content,
|
||||||
|
"eleven minutes is not a theme"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
roles[3],
|
||||||
|
ChapterRole::Ending,
|
||||||
|
"a cast roll at the end is one"
|
||||||
|
);
|
||||||
|
// Only the cast scroll is the tail; the episode is not skipped into.
|
||||||
|
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same name in the release that uses it for the theme song: ninety
|
||||||
|
/// seconds, near the front, and no other opening in the file.
|
||||||
|
#[test]
|
||||||
|
fn a_theme_length_intro_near_the_front_is_an_opening() {
|
||||||
|
let chapters = vec![
|
||||||
|
chapter(0, "Avant", 0.0),
|
||||||
|
chapter(1, "Intro", 90.0),
|
||||||
|
chapter(2, "Part A", 180.0),
|
||||||
|
];
|
||||||
|
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
|
||||||
|
|
||||||
|
// …but not when the file already says which chapter the opening is.
|
||||||
|
let chapters = vec![
|
||||||
|
chapter(0, "Intro", 0.0),
|
||||||
|
chapter(1, "OP", 90.0),
|
||||||
|
chapter(2, "Part A", 180.0),
|
||||||
|
];
|
||||||
|
let roles = roles(&chapters, Some(1440.0));
|
||||||
|
assert_eq!(roles[0], ChapterRole::Content);
|
||||||
|
assert_eq!(roles[1], ChapterRole::Opening);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordinary shape: the credits and the trailer for next week are one
|
||||||
|
/// run at the end, and the countdown starts where the credits do.
|
||||||
|
#[test]
|
||||||
|
fn the_tail_is_the_whole_run_of_credits_and_preview() {
|
||||||
|
let chapters = vec![
|
||||||
|
chapter(0, "OP", 0.0),
|
||||||
|
chapter(1, "Part A", 90.0),
|
||||||
|
chapter(2, "Part B", 700.0),
|
||||||
|
chapter(3, "ED", 1320.0),
|
||||||
|
chapter(4, "Preview", 1410.0),
|
||||||
|
];
|
||||||
|
let roles = roles(&chapters, Some(1440.0));
|
||||||
|
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
|
||||||
|
/// ending, and the preview after that. Only the preview may be skipped —
|
||||||
|
/// treating everything from the ending on as tail would drop a scene.
|
||||||
|
#[test]
|
||||||
|
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
|
||||||
|
let chapters = vec![
|
||||||
|
chapter(0, "Part A", 0.0),
|
||||||
|
chapter(1, "Part B", 700.0),
|
||||||
|
chapter(2, "ED", 1260.0),
|
||||||
|
chapter(3, "Part C", 1350.0),
|
||||||
|
chapter(4, "Preview", 1400.0),
|
||||||
|
];
|
||||||
|
let roles = roles(&chapters, Some(1440.0));
|
||||||
|
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
|
||||||
|
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file that ends on the story has no tail at all, and one with no
|
||||||
|
/// chapters has nothing to read.
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_ends_on_content_has_no_tail() {
|
||||||
|
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
||||||
|
let roles = roles(&chapters, Some(1440.0));
|
||||||
|
assert_eq!(credits_start(&chapters, &roles), None);
|
||||||
|
assert_eq!(credits_start(&[], &[]), None);
|
||||||
|
|
||||||
|
// Everything skippable is a mis-tagged file, not an episode that is
|
||||||
|
// entirely credits: auto-advancing out of it at second zero would be
|
||||||
|
// worse than doing nothing.
|
||||||
|
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
|
||||||
|
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
|
||||||
|
assert_eq!(credits_start(&chapters, &roles), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_position_lands_in_the_chapter_that_contains_it() {
|
||||||
|
let chapters = vec![
|
||||||
|
chapter(0, "Intro", 0.0),
|
||||||
|
chapter(1, "OP", 24.0),
|
||||||
|
chapter(2, "Part A", 114.0),
|
||||||
|
];
|
||||||
|
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
|
||||||
|
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
|
||||||
|
// Exactly on a boundary belongs to the chapter that starts there,
|
||||||
|
// which is what makes "skip" land *out* of the opening rather than at
|
||||||
|
// its last frame.
|
||||||
|
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
|
||||||
|
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
|
||||||
|
let chapters = vec![chapter(0, "OP", 24.0)];
|
||||||
|
assert_eq!(chapter_at(&chapters, 0.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_chapter_ends_where_the_file_does() {
|
||||||
|
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
||||||
|
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
|
||||||
|
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
|
||||||
|
// No duration yet and nothing after it: there is nowhere to skip to,
|
||||||
|
// and a button that seeks to zero would be worse than no button.
|
||||||
|
assert_eq!(chapter_end(&chapters, 1, None), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,16 @@ impl AppDirs {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings only the desktop app has: hardware decoding, the cache budget.
|
||||||
|
pub fn desktop_prefs_file(&self) -> PathBuf {
|
||||||
|
self.config.join("desktop.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop window's size, for the next launch to open at.
|
||||||
|
pub fn window_file(&self) -> PathBuf {
|
||||||
|
self.config.join("window.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// The share list.
|
/// The share list.
|
||||||
pub fn shares_file(&self) -> PathBuf {
|
pub fn shares_file(&self) -> PathBuf {
|
||||||
self.config.join("shares.json")
|
self.config.join("shares.json")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
pub mod appearance;
|
pub mod appearance;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
pub mod chapters;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
|||||||
@@ -331,6 +331,28 @@ impl Library {
|
|||||||
self.titles.is_empty()
|
self.titles.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a new watch state for one file in place. Returns whether the
|
||||||
|
/// file is in the library.
|
||||||
|
///
|
||||||
|
/// What a save during playback or a "mark watched" needs: rebuilding the
|
||||||
|
/// whole library from the catalog for one changed row re-reads and re-sorts
|
||||||
|
/// every file, and on a large share that is a visible pause.
|
||||||
|
pub fn set_watch(&mut self, share_id: &str, link_id: &str, state: WatchState) -> bool {
|
||||||
|
let episode = self
|
||||||
|
.titles
|
||||||
|
.iter_mut()
|
||||||
|
.flat_map(|title| title.seasons.iter_mut())
|
||||||
|
.flat_map(|season| season.episodes.iter_mut())
|
||||||
|
.find(|episode| episode.node.share_id == share_id && episode.node.link_id == link_id);
|
||||||
|
match episode {
|
||||||
|
Some(episode) => {
|
||||||
|
episode.watch = Some(state);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> Option<&Title> {
|
pub fn get(&self, key: &str) -> Option<&Title> {
|
||||||
self.titles.iter().find(|title| title.key == key)
|
self.titles.iter().find(|title| title.key == key)
|
||||||
}
|
}
|
||||||
@@ -347,6 +369,26 @@ impl Library {
|
|||||||
titles
|
titles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every episode that has been started or finished, most recently
|
||||||
|
/// touched first. The history page.
|
||||||
|
///
|
||||||
|
/// An episode marked unwatched keeps a row with position zero, which is
|
||||||
|
/// "never played" for every purpose here, so it is left out.
|
||||||
|
pub fn history(&self) -> Vec<(&Title, &Episode)> {
|
||||||
|
let mut played: Vec<(&Title, &Episode)> = self
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.flat_map(|title| title.episodes().map(move |episode| (title, episode)))
|
||||||
|
.filter(|(_, episode)| {
|
||||||
|
episode
|
||||||
|
.watch
|
||||||
|
.is_some_and(|watch| watch.watched || watch.position_secs > 0.0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played()));
|
||||||
|
played
|
||||||
|
}
|
||||||
|
|
||||||
/// Substring match over the display name and every filename under it, so
|
/// Substring match over the display name and every filename under it, so
|
||||||
/// searching for a release group or an episode name finds its title.
|
/// searching for a release group or an episode name finds its title.
|
||||||
pub fn search(&self, query: &str) -> Vec<&Title> {
|
pub fn search(&self, query: &str) -> Vec<&Title> {
|
||||||
@@ -495,6 +537,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_is_every_played_episode_newest_first_across_titles() {
|
||||||
|
let watch_state = HashMap::from([
|
||||||
|
(
|
||||||
|
("s".to_string(), "a".to_string()),
|
||||||
|
watch(600.0, 1400.0, false, 10),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("s".to_string(), "c".to_string()),
|
||||||
|
watch(1400.0, 1400.0, true, 30),
|
||||||
|
),
|
||||||
|
// Marked unwatched again: a row at zero, which is not a play.
|
||||||
|
(
|
||||||
|
("s".to_string(), "b".to_string()),
|
||||||
|
watch(0.0, 1400.0, false, 40),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let library = Library::build(
|
||||||
|
vec![
|
||||||
|
node("s", "a", "Show - S01E01", Some(1), Some(1)),
|
||||||
|
node("s", "b", "Show - S01E02", Some(1), Some(2)),
|
||||||
|
node("s", "c", "Film", None, None),
|
||||||
|
node("s", "d", "Other - S01E01", Some(1), Some(1)),
|
||||||
|
],
|
||||||
|
&watch_state,
|
||||||
|
);
|
||||||
|
|
||||||
|
let order: Vec<&str> = library
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.map(|(_, episode)| episode.node.link_id.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(order, ["c", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_episodes_into_seasons() {
|
fn groups_episodes_into_seasons() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
@@ -590,6 +667,21 @@ mod tests {
|
|||||||
assert_eq!(library.continue_watching().len(), 1);
|
assert_eq!(library.continue_watching().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||||
|
let nodes = vec![
|
||||||
|
node("s", "1", "Show - S01E01", Some(1), Some(1)),
|
||||||
|
node("s", "2", "Show - S01E02", Some(1), Some(2)),
|
||||||
|
];
|
||||||
|
let mut states = HashMap::new();
|
||||||
|
let mut library = Library::build(nodes.clone(), &states);
|
||||||
|
let state = watch(700.0, 1440.0, false, 20);
|
||||||
|
assert!(library.set_watch("s", "2", state));
|
||||||
|
states.insert(("s".into(), "2".into()), state);
|
||||||
|
assert_eq!(library, Library::build(nodes, &states));
|
||||||
|
assert!(!library.set_watch("s", "9", state));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_up_falls_through_to_the_first_unwatched() {
|
fn next_up_falls_through_to_the_first_unwatched() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ use crate::error::Result;
|
|||||||
/// referenced here because `pstr-core` does not depend on the player.
|
/// referenced here because `pstr-core` does not depend on the player.
|
||||||
const MAX_VOLUME: f64 = 100.0;
|
const MAX_VOLUME: f64 = 100.0;
|
||||||
|
|
||||||
|
/// The range of playback rates offered. Past these, audio stops being speech
|
||||||
|
/// and video stops being watchable, and mpv's own resampler gives up first.
|
||||||
|
const MIN_SPEED: f64 = 0.25;
|
||||||
|
const MAX_SPEED: f64 = 4.0;
|
||||||
|
|
||||||
/// How playback should sound, and in which language.
|
/// How playback should sound, and in which language.
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
// A file written by an older version, or hand-edited, is missing fields rather
|
// A file written by an older version, or hand-edited, is missing fields rather
|
||||||
@@ -43,6 +48,18 @@ pub struct PlaybackPrefs {
|
|||||||
/// does and what a season of anything is watched like. Off is one checkbox
|
/// does and what a season of anything is watched like. Off is one checkbox
|
||||||
/// away for someone who falls asleep to it.
|
/// away for someone who falls asleep to it.
|
||||||
pub autoplay_next: bool,
|
pub autoplay_next: bool,
|
||||||
|
/// Whether an opening or ending is seeked past rather than offered.
|
||||||
|
///
|
||||||
|
/// Off by default, and deliberately: chapter names are a guess about a file
|
||||||
|
/// somebody else muxed, and a wrong guess that only greys out a button
|
||||||
|
/// costs nothing while a wrong guess that seeks costs the scene.
|
||||||
|
pub auto_skip: bool,
|
||||||
|
/// How fast playback runs, 1.0 being the file's own rate.
|
||||||
|
///
|
||||||
|
/// Kept across episodes deliberately: someone who watches at 1.25 watches
|
||||||
|
/// everything at 1.25, and a speed that reset every episode would be a
|
||||||
|
/// setting nobody could use.
|
||||||
|
pub speed: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlaybackPrefs {
|
impl Default for PlaybackPrefs {
|
||||||
@@ -54,6 +71,8 @@ impl Default for PlaybackPrefs {
|
|||||||
subtitle_language: None,
|
subtitle_language: None,
|
||||||
subtitles: true,
|
subtitles: true,
|
||||||
autoplay_next: true,
|
autoplay_next: true,
|
||||||
|
auto_skip: false,
|
||||||
|
speed: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +87,10 @@ impl PlaybackPrefs {
|
|||||||
self.volume = MAX_VOLUME;
|
self.volume = MAX_VOLUME;
|
||||||
}
|
}
|
||||||
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
|
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
|
||||||
|
if !self.speed.is_finite() {
|
||||||
|
self.speed = 1.0;
|
||||||
|
}
|
||||||
|
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
|
||||||
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
||||||
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
||||||
self
|
self
|
||||||
@@ -124,6 +147,8 @@ mod tests {
|
|||||||
subtitle_language: Some("eng".into()),
|
subtitle_language: Some("eng".into()),
|
||||||
subtitles: true,
|
subtitles: true,
|
||||||
autoplay_next: false,
|
autoplay_next: false,
|
||||||
|
auto_skip: true,
|
||||||
|
speed: 1.5,
|
||||||
};
|
};
|
||||||
save(&dirs, &prefs).unwrap();
|
save(&dirs, &prefs).unwrap();
|
||||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||||
|
|||||||
+252
-18
@@ -160,6 +160,49 @@ impl ShareStore {
|
|||||||
Ok(share)
|
Ok(share)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-supply a configured share's secrets, keeping everything else.
|
||||||
|
///
|
||||||
|
/// A secret store can lose the ability to read what it wrote: an Android
|
||||||
|
/// Keystore key is invalidated by a lockscreen change or a device restore,
|
||||||
|
/// and a desktop keyring can be reset. After that every open of the share
|
||||||
|
/// fails — and so, often, does removing it, because removal deletes a
|
||||||
|
/// secret the store can no longer touch. Re-entering the link is the way
|
||||||
|
/// back that does not cost the catalog and the offline files.
|
||||||
|
///
|
||||||
|
/// The link must be the same one: a different token is a different share,
|
||||||
|
/// and quietly repointing an existing library at it would leave every
|
||||||
|
/// catalog row describing files that are no longer there.
|
||||||
|
pub fn replace_secrets(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
url: &str,
|
||||||
|
custom_password: Option<&str>,
|
||||||
|
) -> Result<Share> {
|
||||||
|
let token = token_from_url(url)?;
|
||||||
|
let mut shares = self.list()?;
|
||||||
|
let share = shares
|
||||||
|
.iter_mut()
|
||||||
|
.find(|share| share.id == id)
|
||||||
|
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
|
||||||
|
if share.token != token {
|
||||||
|
return Err(Error::Config(
|
||||||
|
"that link is for a different share; add it as a new one".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let secrets = ShareSecrets {
|
||||||
|
url: url.to_string(),
|
||||||
|
custom_password: custom_password
|
||||||
|
.filter(|password| !password.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
};
|
||||||
|
self.store_secrets(id, &secrets)?;
|
||||||
|
share.has_custom_password = secrets.custom_password.is_some();
|
||||||
|
let updated = share.clone();
|
||||||
|
write_json(&self.dirs.shares_file(), &shares)?;
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
/// Forget a share and its secrets.
|
/// Forget a share and its secrets.
|
||||||
///
|
///
|
||||||
/// The config entry goes first here, for the mirror-image reason: if the
|
/// The config entry goes first here, for the mirror-image reason: if the
|
||||||
@@ -223,10 +266,28 @@ impl SharedLibrary {
|
|||||||
/// the whole library — one revoked link should not make the other three
|
/// the whole library — one revoked link should not make the other three
|
||||||
/// unwatchable.
|
/// unwatchable.
|
||||||
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
|
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||||
|
Self::open_all_reusing(store, BTreeMap::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open every configured share, reusing the clients in `reusable`.
|
||||||
|
///
|
||||||
|
/// Adding or removing one link should not cost a fresh handshake for the
|
||||||
|
/// shares that were already open, so a caller that still holds them can
|
||||||
|
/// hand them back here. A share whose stored secrets changed must *not*
|
||||||
|
/// appear in `reusable` — its old client authenticates against the old
|
||||||
|
/// link.
|
||||||
|
pub async fn open_all_reusing(
|
||||||
|
store: &ShareStore,
|
||||||
|
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
||||||
|
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||||
let mut clients = BTreeMap::new();
|
let mut clients = BTreeMap::new();
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
for share in store.list()? {
|
for share in store.list()? {
|
||||||
|
if let Some(client) = reusable.remove(&share.id) {
|
||||||
|
clients.insert(share.id, client);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match store.open(&share).await {
|
match store.open(&share).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
clients.insert(share.id.clone(), client);
|
clients.insert(share.id.clone(), client);
|
||||||
@@ -238,6 +299,23 @@ impl SharedLibrary {
|
|||||||
Ok((Self { clients }, failures))
|
Ok((Self { clients }, failures))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The opened clients, minus `dropping`, ready to hand to
|
||||||
|
/// [`SharedLibrary::open_all_reusing`].
|
||||||
|
///
|
||||||
|
/// The excluded share is the one whose credentials or membership just
|
||||||
|
/// changed; leaving it out is what makes a removed share's live client
|
||||||
|
/// unreachable rather than merely stale.
|
||||||
|
pub fn reusable_clients(
|
||||||
|
&self,
|
||||||
|
dropping: &str,
|
||||||
|
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
|
||||||
|
self.clients
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, _)| id.as_str() != dropping)
|
||||||
|
.map(|(id, client)| (id.clone(), client.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The client for one share.
|
/// The client for one share.
|
||||||
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
|
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
|
||||||
self.clients.get(share_id)
|
self.clients.get(share_id)
|
||||||
@@ -259,43 +337,91 @@ impl SharedLibrary {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk one share's whole subtree, depth first, yielding every node.
|
/// Walk one share's whole subtree, level by level, yielding every node.
|
||||||
///
|
///
|
||||||
/// There is no recursion helper on the visitor path — the authenticated
|
/// There is no recursion helper on the visitor path — the authenticated
|
||||||
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
|
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
|
||||||
/// there — so the walk is here.
|
/// there — so the walk is here.
|
||||||
|
///
|
||||||
|
/// **Breadth first and fanned out, because a media share is wide.** The
|
||||||
|
/// shape of one is a root holding a folder per series, each holding a folder
|
||||||
|
/// per season: a couple of hundred folders that are each one listing request
|
||||||
|
/// plus a detail fetch, and almost all of them siblings. Walking that one
|
||||||
|
/// folder at a time spends the whole crawl waiting on a round trip it could
|
||||||
|
/// have overlapped — the crawl was minutes of latency and seconds of work.
|
||||||
|
/// Taking a whole level at once is what collapses it.
|
||||||
|
///
|
||||||
|
/// The client is cheap to clone and its clones share one node-key cache and
|
||||||
|
/// one single-flight map, so siblings that need the same ancestor key wait
|
||||||
|
/// on one derivation rather than each starting their own. `buffered` rather
|
||||||
|
/// than `buffer_unordered`: the crawl's output order is otherwise a race,
|
||||||
|
/// and a catalog that reorders itself between two identical crawls is
|
||||||
|
/// needlessly hard to diff.
|
||||||
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
|
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
|
||||||
|
self.crawl_reporting(share_id, |_| {}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::crawl`], calling `progress` with how many nodes have been found
|
||||||
|
/// so far each time a folder's listing comes back.
|
||||||
|
///
|
||||||
|
/// A crawl of a large share takes minutes, and "crawling…" for minutes
|
||||||
|
/// reads as stuck. A count that keeps rising does not.
|
||||||
|
pub async fn crawl_reporting(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
progress: impl Fn(usize),
|
||||||
|
) -> Result<Vec<Node>> {
|
||||||
|
use futures::stream::{StreamExt as _, TryStreamExt as _};
|
||||||
|
|
||||||
let client = self
|
let client = self
|
||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
|
|
||||||
let root = client.get_root_node().await?;
|
let root = client.get_root_node().await?;
|
||||||
let mut found = Vec::new();
|
let mut level = vec![root.uid.clone()];
|
||||||
let mut folders = vec![root.uid.clone()];
|
let mut found = vec![root];
|
||||||
found.push(root);
|
|
||||||
|
|
||||||
while let Some(folder) = folders.pop() {
|
while !level.is_empty() {
|
||||||
let child_uids: Vec<NodeUid> =
|
let listings = level.into_iter().map(|folder| {
|
||||||
client.enumerate_folder_children_node_uids(&folder).await?;
|
let client = client.clone();
|
||||||
if child_uids.is_empty() {
|
async move {
|
||||||
continue;
|
let child_uids: Vec<NodeUid> =
|
||||||
}
|
client.enumerate_folder_children_node_uids(&folder).await?;
|
||||||
// Chunked and fanned out inside the SDK, and the node keys it
|
if child_uids.is_empty() {
|
||||||
// unlocks along the way are cached there, so a deep tree does not
|
return Ok(Vec::new());
|
||||||
// re-derive the same ancestors per child.
|
}
|
||||||
let children = client.enumerate_nodes(&child_uids).await?;
|
// Chunked and fanned out inside the SDK too, so this is a
|
||||||
for child in children {
|
// multiplier on that concurrency rather than the only one.
|
||||||
if child.is_folder() {
|
client.enumerate_nodes(&child_uids).await
|
||||||
folders.push(child.uid.clone());
|
|
||||||
}
|
}
|
||||||
found.push(child);
|
});
|
||||||
|
|
||||||
|
let mut listings = futures::stream::iter(listings).buffered(CRAWL_CONCURRENCY);
|
||||||
|
let mut next = Vec::new();
|
||||||
|
while let Some(children) = listings.try_next().await? {
|
||||||
|
for child in children {
|
||||||
|
if child.is_folder() {
|
||||||
|
next.push(child.uid.clone());
|
||||||
|
}
|
||||||
|
found.push(child);
|
||||||
|
}
|
||||||
|
progress(found.len());
|
||||||
}
|
}
|
||||||
|
level = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(found)
|
Ok(found)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many folders of one level to list at once.
|
||||||
|
///
|
||||||
|
/// Each of these fans out again inside the SDK — up to four detail batches per
|
||||||
|
/// folder — so this is not the number of requests in flight but a multiplier on
|
||||||
|
/// it. Six keeps the ceiling in the low tens: enough to hide the round trip on
|
||||||
|
/// a wide level, well short of a burst a share server would read as abuse.
|
||||||
|
const CRAWL_CONCURRENCY: usize = 6;
|
||||||
|
|
||||||
/// The app-version string Proton identifies this client by.
|
/// The app-version string Proton identifies this client by.
|
||||||
///
|
///
|
||||||
/// The `external-drive-` prefix is **not** decoration: the API parses the part
|
/// The `external-drive-` prefix is **not** decoration: the API parses the part
|
||||||
@@ -316,6 +442,15 @@ fn client_configuration() -> ProtonClientConfiguration {
|
|||||||
/// Parsed here as well as in the SDK because the store needs a stable id for a
|
/// Parsed here as well as in the SDK because the store needs a stable id for a
|
||||||
/// share *before* it has opened it — and the id must not be derived from the
|
/// share *before* it has opened it — and the id must not be derived from the
|
||||||
/// password, which would put a secret in the config file by the back door.
|
/// password, which would put a secret in the config file by the back door.
|
||||||
|
/// The share token a link names, or why it is not a usable share link.
|
||||||
|
///
|
||||||
|
/// Public so a form can say what is wrong with a link while it is still being
|
||||||
|
/// pasted, rather than after a round trip through [`ShareStore::add`] — which
|
||||||
|
/// asks exactly this first.
|
||||||
|
pub fn share_token(url: &str) -> Result<String> {
|
||||||
|
token_from_url(url)
|
||||||
|
}
|
||||||
|
|
||||||
fn token_from_url(url: &str) -> Result<String> {
|
fn token_from_url(url: &str) -> Result<String> {
|
||||||
let (_, tail) = url
|
let (_, tail) = url
|
||||||
.split_once("/urls/")
|
.split_once("/urls/")
|
||||||
@@ -396,6 +531,105 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A store that has forgotten how to read what it wrote, which is what an
|
||||||
|
/// invalidated Android Keystore key looks like from Rust.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Unreadable {
|
||||||
|
entries: std::sync::Mutex<BTreeMap<String, String>>,
|
||||||
|
readable: std::sync::atomic::AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretStore for Unreadable {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key.to_owned(), value.to_owned());
|
||||||
|
self.readable
|
||||||
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
if !self.readable.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return Err(Error::Config("key permanently invalidated".into()));
|
||||||
|
}
|
||||||
|
Ok(self.entries.lock().unwrap().get(key).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
self.entries.lock().unwrap().remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_in(name: &str) -> (ShareStore, Arc<Unreadable>) {
|
||||||
|
let root = std::env::temp_dir().join(format!("pstr-shares-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let dirs = AppDirs::from_paths(root.join("config"), root.join("data"), root.join("cache"))
|
||||||
|
.expect("app directories");
|
||||||
|
let secrets = Arc::new(Unreadable::default());
|
||||||
|
(
|
||||||
|
ShareStore::with_secret_store(dirs, secrets.clone()),
|
||||||
|
secrets,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The way back from an unreadable secret store. The share keeps its id, so
|
||||||
|
/// the catalog rows and offline files keyed by it stay valid — which is the
|
||||||
|
/// whole reason this exists rather than Remove-and-re-add.
|
||||||
|
#[test]
|
||||||
|
fn a_re_entered_link_restores_a_share_whose_secret_cannot_be_read() {
|
||||||
|
let (store, secrets) = store_in("repair");
|
||||||
|
let url = "https://drive.proton.me/urls/ABC123#s3cr3t";
|
||||||
|
let added = store.add("Library", url, None).expect("add");
|
||||||
|
|
||||||
|
secrets
|
||||||
|
.readable
|
||||||
|
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert!(store.load_secrets(&added.id).is_err());
|
||||||
|
|
||||||
|
let repaired = store
|
||||||
|
.replace_secrets(&added.id, url, Some("new-password"))
|
||||||
|
.expect("replace");
|
||||||
|
assert_eq!(repaired.id, added.id);
|
||||||
|
assert!(repaired.has_custom_password);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list()
|
||||||
|
.expect("list")
|
||||||
|
.iter()
|
||||||
|
.any(|share| share.id == added.id && share.has_custom_password)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A different link is a different share. Repointing this one at it would
|
||||||
|
/// leave every catalog row and offline file describing files that are not
|
||||||
|
/// there.
|
||||||
|
#[test]
|
||||||
|
fn a_re_entered_link_for_a_different_share_is_refused() {
|
||||||
|
let (store, _) = store_in("repair-mismatch");
|
||||||
|
let added = store
|
||||||
|
.add(
|
||||||
|
"Library",
|
||||||
|
"https://drive.proton.me/urls/ABC123#s3cr3t",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("add");
|
||||||
|
|
||||||
|
let error = store
|
||||||
|
.replace_secrets(
|
||||||
|
&added.id,
|
||||||
|
"https://drive.proton.me/urls/DEF456#s3cr3t",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("a different token must be refused");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("different share"),
|
||||||
|
"says what is wrong: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The id must be derivable from the token alone — deriving it from the URL
|
/// The id must be derivable from the token alone — deriving it from the URL
|
||||||
/// would mix a secret into the config file.
|
/// would mix a secret into the config file.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ authors.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pstr-core.workspace = true
|
pstr-core.workspace = true
|
||||||
|
# `time` for the rate limiter's sleeps, `sync` for the mutex it paces behind.
|
||||||
|
tokio = { workspace = true, features = ["time", "sync"] }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
@@ -19,4 +21,4 @@ tracing.workspace = true
|
|||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|
||||||
@@ -14,11 +14,30 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::limiter::RateLimiter;
|
||||||
use crate::matching::{Candidate, Query};
|
use crate::matching::{Candidate, Query};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
|
|
||||||
const ENDPOINT: &str = "https://graphql.anilist.co";
|
const ENDPOINT: &str = "https://graphql.anilist.co";
|
||||||
|
|
||||||
|
/// What AniList documents as its ceiling: ninety requests a minute.
|
||||||
|
///
|
||||||
|
/// A starting point, not a belief — the API has been running in a degraded mode
|
||||||
|
/// that allows thirty for a long time now, and says so in `X-RateLimit-Limit` on
|
||||||
|
/// every answer. The limiter retunes itself off that header after the first
|
||||||
|
/// request, so the only cost of starting optimistic is one `429` on a run that
|
||||||
|
/// begins during a degraded window.
|
||||||
|
const REQUESTS_PER_MINUTE: u32 = 90;
|
||||||
|
|
||||||
|
/// How many times to sit out a `429` before giving up on one request.
|
||||||
|
///
|
||||||
|
/// Two, because the pause it asks for is the rest of its rate-limit window and
|
||||||
|
/// the limiter holds every other lookup for the same stretch — a third round is
|
||||||
|
/// minutes of a scan spent waiting rather than matching. Giving up returns
|
||||||
|
/// [`Error::RateLimited`], which is a failure and therefore not cached: the
|
||||||
|
/// title stays askable and the next run picks it up.
|
||||||
|
const RETRIES: usize = 2;
|
||||||
|
|
||||||
/// How many answers to score. AniList's own relevance ordering is good, and the
|
/// How many answers to score. AniList's own relevance ordering is good, and the
|
||||||
/// right title is essentially always in the first handful; asking for more costs
|
/// right title is essentially always in the first handful; asking for more costs
|
||||||
/// them bandwidth and buys nothing.
|
/// them bandwidth and buys nothing.
|
||||||
@@ -66,11 +85,16 @@ query ($id: Int) {
|
|||||||
|
|
||||||
pub struct AniList {
|
pub struct AniList {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
/// Shared by every lookup in flight — see [`crate::limiter`].
|
||||||
|
limiter: RateLimiter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AniList {
|
impl AniList {
|
||||||
pub fn new(http: reqwest::Client) -> Self {
|
pub fn new(http: reqwest::Client) -> Self {
|
||||||
Self { http }
|
Self {
|
||||||
|
http,
|
||||||
|
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,24 +156,40 @@ impl Provider for AniList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AniList {
|
impl AniList {
|
||||||
/// One GraphQL request, with AniList's two ways of failing — an HTTP status
|
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
||||||
/// and an `errors` array under a 200 — folded into one.
|
/// status and an `errors` array under a 200 — folded into one.
|
||||||
|
///
|
||||||
|
/// A `429` is waited out rather than returned: a scan sends hundreds of
|
||||||
|
/// these, and the request that trips the limit is not the one that was
|
||||||
|
/// wrong. See [`crate::limiter`].
|
||||||
async fn query<T: serde::de::DeserializeOwned>(
|
async fn query<T: serde::de::DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
) -> Result<Option<T>> {
|
) -> Result<Option<T>> {
|
||||||
let response = self.http.post(ENDPOINT).json(&body).send().await?;
|
let mut attempts = 0usize;
|
||||||
let status = response.status();
|
let response = loop {
|
||||||
if !status.is_success() {
|
self.limiter.acquire().await;
|
||||||
// 429 is the one worth naming: AniList rate-limits by the minute,
|
let response = self.http.post(ENDPOINT).json(&body).send().await?;
|
||||||
// and a caller that knows it was throttled can back off rather than
|
let status = response.status();
|
||||||
// cache a miss for a title that would have matched.
|
|
||||||
return Err(if status.as_u16() == 429 {
|
if status.as_u16() == 429 {
|
||||||
Error::RateLimited
|
// The pause holds every other lookup too, so the rest of the
|
||||||
} else {
|
// run does not spend the window earning more 429s.
|
||||||
Error::Http(format!("AniList answered {status}"))
|
let waited = self.limiter.throttled(response.headers()).await;
|
||||||
});
|
attempts += 1;
|
||||||
}
|
if attempts > RETRIES {
|
||||||
|
return Err(Error::RateLimited);
|
||||||
|
}
|
||||||
|
tracing::debug!("AniList is throttling; waiting {waited:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.limiter.observe(response.headers()).await;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::Http(format!("AniList answered {status}")));
|
||||||
|
}
|
||||||
|
break response;
|
||||||
|
};
|
||||||
|
|
||||||
let payload: Response<T> = response.json().await?;
|
let payload: Response<T> = response.json().await?;
|
||||||
if let Some(errors) = payload.errors
|
if let Some(errors) = payload.errors
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
pub mod anilist;
|
pub mod anilist;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod limiter;
|
||||||
pub mod matching;
|
pub mod matching;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Spacing requests out, so a provider does not have to.
|
||||||
|
//!
|
||||||
|
//! Scanning a new library is the one moment this app talks to a metadata
|
||||||
|
//! provider in bulk: every title is one search, a matched series is one more
|
||||||
|
//! request per season, and a hundred-folder share is several hundred requests
|
||||||
|
//! in a burst. AniList allows ninety a minute — thirty while its API is in the
|
||||||
|
//! degraded mode it has been in for a while — and answers everything past that
|
||||||
|
//! with a `429`. Without pacing, the first thirty titles match and the rest of
|
||||||
|
//! the run fails; because a failure is deliberately not cached (see
|
||||||
|
//! [`crate::service`]), the next scan starts over and fails in the same place.
|
||||||
|
//!
|
||||||
|
//! So requests take a slot here first. The rate is not a constant but the
|
||||||
|
//! provider's own: `X-RateLimit-Limit` on any answer retunes it, and a `429`
|
||||||
|
//! parks every caller for as long as `Retry-After` asks. One limiter is shared
|
||||||
|
//! by every lookup in flight — it lives in the provider, the provider lives in
|
||||||
|
//! an `Arc` inside [`crate::MetadataService`], and cloning the service to hand
|
||||||
|
//! it to a worker shares the limiter rather than copying it.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
/// Never faster than this, whatever a header claims.
|
||||||
|
const MIN_INTERVAL: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// Never slower than this. A provider that asks for a longer pause gets it via
|
||||||
|
/// [`RateLimiter::throttled`]; this is only the floor the steady rate decays to,
|
||||||
|
/// so a misread header cannot wedge a scan forever.
|
||||||
|
const MAX_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long to honour a `429` that came with no `Retry-After`.
|
||||||
|
const DEFAULT_BACKOFF: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// The longest a single throttle may park requests for.
|
||||||
|
///
|
||||||
|
/// A scan is something a person is watching happen. Two minutes of silence is
|
||||||
|
/// worse than a partial answer they can ask to complete later, and the titles
|
||||||
|
/// that did not get through stay unmatched and askable.
|
||||||
|
const MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// A shared, self-tuning pacer for one provider's API.
|
||||||
|
pub struct RateLimiter {
|
||||||
|
state: Mutex<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
/// The earliest a request may be sent.
|
||||||
|
next: Instant,
|
||||||
|
/// The spacing between requests.
|
||||||
|
interval: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
/// A limiter starting at `per_minute` requests a minute.
|
||||||
|
///
|
||||||
|
/// A starting point only: the first answer that carries a limit header
|
||||||
|
/// replaces it with what the provider actually allows right now.
|
||||||
|
pub fn per_minute(per_minute: u32) -> Self {
|
||||||
|
Self::new(interval_for(per_minute.max(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(interval: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Mutex::new(State {
|
||||||
|
next: Instant::now(),
|
||||||
|
interval,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until a request may be sent, then claim that slot.
|
||||||
|
///
|
||||||
|
/// Re-checks after every sleep rather than reserving a slot up front, so a
|
||||||
|
/// [`throttled`](Self::throttled) that lands while callers are already
|
||||||
|
/// waiting holds them too — which is the whole point of a shared limiter
|
||||||
|
/// during a burst.
|
||||||
|
pub async fn acquire(&self) {
|
||||||
|
loop {
|
||||||
|
let wait = {
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
if state.next <= now {
|
||||||
|
state.next = now + state.interval;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.next - now
|
||||||
|
};
|
||||||
|
tokio::time::sleep(wait).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retune from a provider's own rate-limit headers.
|
||||||
|
pub async fn observe(&self, headers: &reqwest::header::HeaderMap) {
|
||||||
|
let Some(limit) = header_number(headers, "x-ratelimit-limit") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let interval = interval_for(limit.max(1) as u32);
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
if state.interval != interval {
|
||||||
|
tracing::debug!("provider allows {limit}/min; pacing at {interval:?}");
|
||||||
|
state.interval = interval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park every caller after a `429`.
|
||||||
|
///
|
||||||
|
/// `Retry-After` is seconds, and is the provider telling us exactly how
|
||||||
|
/// long its window has left to run — a shorter guess would just earn
|
||||||
|
/// another `429`.
|
||||||
|
pub async fn throttled(&self, headers: &reqwest::header::HeaderMap) -> Duration {
|
||||||
|
let wait = header_number(headers, "retry-after")
|
||||||
|
.map(|seconds| Duration::from_secs(seconds.max(1)))
|
||||||
|
.unwrap_or(DEFAULT_BACKOFF)
|
||||||
|
.min(MAX_BACKOFF);
|
||||||
|
|
||||||
|
let mut state = self.state.lock().await;
|
||||||
|
let until = Instant::now() + wait;
|
||||||
|
if until > state.next {
|
||||||
|
state.next = until;
|
||||||
|
}
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `90` a minute → one every 667ms.
|
||||||
|
fn interval_for(per_minute: u32) -> Duration {
|
||||||
|
Duration::from_secs_f64(60.0 / f64::from(per_minute)).clamp(MIN_INTERVAL, MAX_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_number(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
|
||||||
|
headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn headers(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
|
||||||
|
let mut map = reqwest::header::HeaderMap::new();
|
||||||
|
for (name, value) in pairs {
|
||||||
|
map.insert(
|
||||||
|
reqwest::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"),
|
||||||
|
value.parse().expect("header value"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rate_becomes_the_spacing_it_implies() {
|
||||||
|
assert_eq!(interval_for(60), Duration::from_secs(1));
|
||||||
|
assert_eq!(interval_for(30), Duration::from_secs(2));
|
||||||
|
// Clamped at both ends: a provider claiming one request a minute must
|
||||||
|
// not stall a scan for a minute per title, and one claiming a million
|
||||||
|
// still gets paced.
|
||||||
|
assert_eq!(interval_for(1), MAX_INTERVAL);
|
||||||
|
assert_eq!(interval_for(1_000_000), MIN_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_first_request_of_a_run_does_not_wait() {
|
||||||
|
let limiter = RateLimiter::per_minute(30);
|
||||||
|
let started = Instant::now();
|
||||||
|
limiter.acquire().await;
|
||||||
|
assert!(started.elapsed() < Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one that matters: two requests in a row are spaced, not sent
|
||||||
|
/// together.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_second_request_waits_out_the_interval() {
|
||||||
|
let limiter = RateLimiter::per_minute(60);
|
||||||
|
limiter.acquire().await;
|
||||||
|
let started = Instant::now();
|
||||||
|
limiter.acquire().await;
|
||||||
|
assert!(started.elapsed() >= Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_429_parks_the_next_request_for_as_long_as_it_asked() {
|
||||||
|
let limiter = RateLimiter::per_minute(600);
|
||||||
|
let waited = limiter.throttled(&headers(&[("retry-after", "12")])).await;
|
||||||
|
assert_eq!(waited, Duration::from_secs(12));
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
limiter.acquire().await;
|
||||||
|
assert!(started.elapsed() >= Duration::from_secs(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A throttle with no header still has to park for something, and a
|
||||||
|
/// ridiculous one is capped — a scan is something a person is watching.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_429_without_a_header_backs_off_by_a_capped_default() {
|
||||||
|
let limiter = RateLimiter::per_minute(90);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.throttled(&headers(&[])).await,
|
||||||
|
DEFAULT_BACKOFF.min(MAX_BACKOFF)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter
|
||||||
|
.throttled(&headers(&[("retry-after", "86400")]))
|
||||||
|
.await,
|
||||||
|
MAX_BACKOFF
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_provider_s_own_limit_retunes_the_pacing() {
|
||||||
|
let limiter = RateLimiter::per_minute(90);
|
||||||
|
limiter
|
||||||
|
.observe(&headers(&[("x-ratelimit-limit", "30")]))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
limiter.state.lock().await.interval,
|
||||||
|
Duration::from_secs(2),
|
||||||
|
"AniList's degraded mode allows 30/min and says so on every answer"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nonsense, and an absent header, leave the pacing alone.
|
||||||
|
limiter
|
||||||
|
.observe(&headers(&[("x-ratelimit-limit", "soon")]))
|
||||||
|
.await;
|
||||||
|
limiter.observe(&headers(&[])).await;
|
||||||
|
assert_eq!(limiter.state.lock().await.interval, Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,6 +223,54 @@ impl MetadataService {
|
|||||||
Ok(episodes)
|
Ok(episodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The episode list for a whole title, with a failure treated as an empty
|
||||||
|
/// one.
|
||||||
|
///
|
||||||
|
/// Deliberately not fatal to the title it belongs to: a poster and a
|
||||||
|
/// synopsis that arrived are worth keeping even when the episode request
|
||||||
|
/// was the one that hit the rate limit. The empty result is not cached as
|
||||||
|
/// an answer — the next match run asks again — because the only thing that
|
||||||
|
/// triggers one is a viewer pressing the button.
|
||||||
|
///
|
||||||
|
/// The match itself only ever answers for *one* entry, and on a provider
|
||||||
|
/// that files each sequel separately — AniList — that entry is season one.
|
||||||
|
/// So each further season of the title is searched for by name and its
|
||||||
|
/// episodes are tagged with the season they came from; without that,
|
||||||
|
/// seasons two and three have no episode names at all. See
|
||||||
|
/// [`MetadataService::season_episodes`].
|
||||||
|
pub async fn title_episodes(
|
||||||
|
&self,
|
||||||
|
title: &Title,
|
||||||
|
found: &TitleMetadata,
|
||||||
|
) -> Vec<EpisodeMetadata> {
|
||||||
|
let mut episodes = self
|
||||||
|
.episodes(found)
|
||||||
|
.await
|
||||||
|
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !self.splits_seasons() {
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Season one is what the title's own match already answered for.
|
||||||
|
let later: Vec<u32> = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.filter_map(|season| season.number)
|
||||||
|
.filter(|number| *number > 1)
|
||||||
|
.collect();
|
||||||
|
for season in later {
|
||||||
|
match self.season_episodes(title, season).await {
|
||||||
|
Ok(found) => episodes.extend(found),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("episodes for {} season {season}: {error}", title.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episodes
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this provider files a sequel as its own entry, so that a title
|
/// Whether this provider files a sequel as its own entry, so that a title
|
||||||
/// with several seasons has to be searched for once per season.
|
/// with several seasons has to be searched for once per season.
|
||||||
pub fn splits_seasons(&self) -> bool {
|
pub fn splits_seasons(&self) -> bool {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::limiter::RateLimiter;
|
||||||
use crate::matching::{Candidate, Query};
|
use crate::matching::{Candidate, Query};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
|
|
||||||
@@ -37,13 +38,23 @@ const STILL_SIZE: &str = "w300";
|
|||||||
/// at here, give or take; `original` is several megabytes of print-resolution
|
/// at here, give or take; `original` is several megabytes of print-resolution
|
||||||
/// artwork per title, which is bandwidth spent on pixels no tile can show.
|
/// artwork per title, which is bandwidth spent on pixels no tile can show.
|
||||||
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
||||||
|
|
||||||
const POSTER_SIZE: &str = "w500";
|
const POSTER_SIZE: &str = "w500";
|
||||||
const BACKDROP_SIZE: &str = "w780";
|
const BACKDROP_SIZE: &str = "w780";
|
||||||
|
|
||||||
|
/// TMDB's published ceiling is around fifty requests a second and it no longer
|
||||||
|
/// enforces a daily one. This is well under it: the point of pacing here is a
|
||||||
|
/// burst that would look like abuse, not a rate anyone is close to.
|
||||||
|
const REQUESTS_PER_MINUTE: u32 = 600;
|
||||||
|
|
||||||
|
/// As [`crate::anilist`]'s.
|
||||||
|
const RETRIES: usize = 2;
|
||||||
|
|
||||||
pub struct Tmdb {
|
pub struct Tmdb {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
language: String,
|
language: String,
|
||||||
|
limiter: RateLimiter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tmdb {
|
impl Tmdb {
|
||||||
@@ -52,6 +63,7 @@ impl Tmdb {
|
|||||||
http,
|
http,
|
||||||
api_key,
|
api_key,
|
||||||
language,
|
language,
|
||||||
|
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,28 +84,12 @@ impl Provider for Tmdb {
|
|||||||
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self
|
let payload: SearchResponse = self
|
||||||
.http
|
.get(
|
||||||
.get(ENDPOINT)
|
ENDPOINT,
|
||||||
.query(&[
|
&[("query", query.name.as_str()), ("include_adult", "false")],
|
||||||
("api_key", self.api_key.as_str()),
|
)
|
||||||
("query", query.name.as_str()),
|
|
||||||
("language", self.language.as_str()),
|
|
||||||
("include_adult", "false"),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(match status.as_u16() {
|
|
||||||
401 => Error::MissingApiKey(ProviderId::Tmdb),
|
|
||||||
429 => Error::RateLimited,
|
|
||||||
_ => Error::Http(format!("TMDB answered {status}")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload: SearchResponse = response.json().await?;
|
|
||||||
Ok(payload
|
Ok(payload
|
||||||
.results
|
.results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -172,7 +168,11 @@ impl Provider for Tmdb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Tmdb {
|
impl Tmdb {
|
||||||
/// One authenticated GET, with TMDB's failures named.
|
/// One authenticated GET, paced, with TMDB's failures named.
|
||||||
|
///
|
||||||
|
/// TMDB is far more generous than AniList and a library-sized run rarely
|
||||||
|
/// troubles it, but the pacing costs nothing here and the `429` handling is
|
||||||
|
/// the same code — see [`crate::limiter`].
|
||||||
async fn get<T: serde::de::DeserializeOwned>(
|
async fn get<T: serde::de::DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -181,23 +181,40 @@ impl Tmdb {
|
|||||||
if self.api_key.trim().is_empty() {
|
if self.api_key.trim().is_empty() {
|
||||||
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
||||||
}
|
}
|
||||||
let mut request = self.http.get(url).query(&[
|
|
||||||
("api_key", self.api_key.as_str()),
|
|
||||||
("language", self.language.as_str()),
|
|
||||||
]);
|
|
||||||
for pair in extra {
|
|
||||||
request = request.query(&[pair]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = request.send().await?;
|
let mut attempts = 0usize;
|
||||||
let status = response.status();
|
let response = loop {
|
||||||
if !status.is_success() {
|
let mut request = self.http.get(url).query(&[
|
||||||
return Err(match status.as_u16() {
|
("api_key", self.api_key.as_str()),
|
||||||
401 => Error::MissingApiKey(ProviderId::Tmdb),
|
("language", self.language.as_str()),
|
||||||
429 => Error::RateLimited,
|
]);
|
||||||
_ => Error::Http(format!("TMDB answered {status}")),
|
for pair in extra {
|
||||||
});
|
request = request.query(&[pair]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.limiter.acquire().await;
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if status.as_u16() == 429 {
|
||||||
|
let waited = self.limiter.throttled(response.headers()).await;
|
||||||
|
attempts += 1;
|
||||||
|
if attempts > RETRIES {
|
||||||
|
return Err(Error::RateLimited);
|
||||||
|
}
|
||||||
|
tracing::debug!("TMDB is throttling; waiting {waited:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(match status.as_u16() {
|
||||||
|
401 => Error::MissingApiKey(ProviderId::Tmdb),
|
||||||
|
_ => Error::Http(format!("TMDB answered {status}")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break response;
|
||||||
|
};
|
||||||
|
|
||||||
Ok(response.json().await?)
|
Ok(response.json().await?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ rust-version.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# For `chapters`, which Android reaches through its own bridge and so cannot
|
||||||
|
# live here. Already in the tree via pstr-stream.
|
||||||
|
pstr-core.workspace = true
|
||||||
pstr-stream.workspace = true
|
pstr-stream.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -1,280 +1,19 @@
|
|||||||
//! The chapters a file was muxed with, and what they are for.
|
//! Reading the chapter list out of the player.
|
||||||
|
//!
|
||||||
|
//! The chapters themselves — what a name claims, what the rest of the file
|
||||||
|
//! settles, where the tail begins — live in [`pstr_core::chapters`], because
|
||||||
|
//! Android reads its chapter list through its own JNI adapter and must reach
|
||||||
|
//! the same verdict as this crate does. Only the mpv read is here.
|
||||||
//!
|
//!
|
||||||
//! Read the same way as the track list — `chapter-list/N/title`, one
|
//! Read the same way as the track list — `chapter-list/N/title`, one
|
||||||
//! sub-property at a time — for the same reason: those are plain strings and
|
//! sub-property at a time — for the same reason: those are plain strings and
|
||||||
//! doubles, and a node walk would buy nothing.
|
//! doubles, and a node walk would buy nothing.
|
||||||
//!
|
|
||||||
//! Anime releases mark their opening and ending as chapters, which is the whole
|
|
||||||
//! reason this exists: with them a player can offer to skip the ninety seconds
|
|
||||||
//! everyone skips, and without them it cannot.
|
|
||||||
|
|
||||||
use libmpv2::Mpv;
|
use libmpv2::Mpv;
|
||||||
|
|
||||||
/// One chapter of the file.
|
pub use pstr_core::chapters::{
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles,
|
||||||
pub struct Chapter {
|
};
|
||||||
/// Position in the list, which is what mpv's `chapter` property is set to.
|
|
||||||
pub index: i64,
|
|
||||||
/// The name the muxer gave it, if any.
|
|
||||||
pub title: Option<String>,
|
|
||||||
/// Where it starts, in seconds.
|
|
||||||
pub start: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Chapter {
|
|
||||||
/// What to show in a list. A nameless chapter is still worth an entry —
|
|
||||||
/// it is a place in the file — so it gets its number.
|
|
||||||
pub fn label(&self) -> String {
|
|
||||||
match &self.title {
|
|
||||||
Some(title) => title.clone(),
|
|
||||||
None => format!("Chapter {}", self.index + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What this chapter is, from its name alone.
|
|
||||||
///
|
|
||||||
/// Prefer [`roles`], which resolves the names that only mean one thing *in
|
|
||||||
/// context* — a chapter called `Intro` is an opening in one release and the
|
|
||||||
/// first ten minutes of the story in another.
|
|
||||||
pub fn role(&self) -> ChapterRole {
|
|
||||||
match Claim::of(self.title.as_deref().unwrap_or_default()) {
|
|
||||||
Claim::Opening => ChapterRole::Opening,
|
|
||||||
Claim::Ending => ChapterRole::Ending,
|
|
||||||
Claim::Preview => ChapterRole::Preview,
|
|
||||||
// Unresolved without the rest of the file: assume it is content,
|
|
||||||
// which is the reading that never skips anything.
|
|
||||||
_ => ChapterRole::Content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The kinds of chapter worth treating differently from "part of the episode".
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ChapterRole {
|
|
||||||
/// The opening titles.
|
|
||||||
Opening,
|
|
||||||
/// The ending titles.
|
|
||||||
Ending,
|
|
||||||
/// A preview of the next episode, after the ending.
|
|
||||||
Preview,
|
|
||||||
/// Anything else, including everything unnamed.
|
|
||||||
Content,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChapterRole {
|
|
||||||
/// What a button offering to skip this should say. `None` for a chapter
|
|
||||||
/// nobody wants skipped.
|
|
||||||
pub fn skip_label(self) -> Option<&'static str> {
|
|
||||||
match self {
|
|
||||||
Self::Opening => Some("Skip opening"),
|
|
||||||
Self::Ending => Some("Skip ending"),
|
|
||||||
Self::Preview => Some("Skip preview"),
|
|
||||||
Self::Content => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this is something the file could end on without the viewer
|
|
||||||
/// missing anything: credits and a trailer for next week.
|
|
||||||
///
|
|
||||||
/// An *opening* is not, even though it is skippable — a file does not end
|
|
||||||
/// with one, and treating it as tail would make a mis-tagged chapter
|
|
||||||
/// auto-advance out of the episode.
|
|
||||||
pub fn ends_the_episode(self) -> bool {
|
|
||||||
matches!(self, Self::Ending | Self::Preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a chapter's *name* claims, before the rest of the file is consulted.
|
|
||||||
///
|
|
||||||
/// The two `Maybe` variants are the whole reason this is separate from
|
|
||||||
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
|
|
||||||
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
|
|
||||||
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
|
|
||||||
/// files and a mid-episode scene in a few. Neither can be settled by the name.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum Claim {
|
|
||||||
Opening,
|
|
||||||
Ending,
|
|
||||||
Preview,
|
|
||||||
/// Reads like an opening, but only if the file has no real one and it is
|
|
||||||
/// the length of a theme song.
|
|
||||||
MaybeOpening,
|
|
||||||
/// Reads like credits, but only near the end of the file or at theme
|
|
||||||
/// length.
|
|
||||||
MaybeEnding,
|
|
||||||
Content,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long an opening or ending theme runs.
|
|
||||||
///
|
|
||||||
/// Anime themes are ninety seconds by convention and rarely stray far. This is
|
|
||||||
/// the corroboration for a name that could go either way: a chapter called
|
|
||||||
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
|
|
||||||
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
|
|
||||||
|
|
||||||
/// Credits can run longer than a theme — a full cast roll over a still — so a
|
|
||||||
/// chapter that only *reads* like an ending gets this much room when it sits at
|
|
||||||
/// the end of the file.
|
|
||||||
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
|
|
||||||
|
|
||||||
/// An opening that far into the file is not an opening. Cold opens run long in
|
|
||||||
/// a first episode, so this is generous.
|
|
||||||
const OPENING_BEFORE_SECONDS: f64 = 480.0;
|
|
||||||
|
|
||||||
/// Where the last part of a file starts, as a fraction of it. A chapter that
|
|
||||||
/// merely reads like credits is taken as credits past this point.
|
|
||||||
const TAIL_FRACTION: f64 = 0.75;
|
|
||||||
|
|
||||||
impl Claim {
|
|
||||||
/// Read a claim out of a chapter name.
|
|
||||||
///
|
|
||||||
/// Matched on whole words from the front, not on substrings: `Operation
|
|
||||||
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
|
|
||||||
/// an ending, and skipping a viewer out of either is a mistake they cannot
|
|
||||||
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
|
|
||||||
/// Opening`) is stepped over first, because plenty of muxes carry it.
|
|
||||||
fn of(title: &str) -> Self {
|
|
||||||
// Japanese names appear verbatim in releases from JP sources, and they
|
|
||||||
// are unambiguous where the English ones are not. Checked before the
|
|
||||||
// word split, which only knows about ASCII and would find no words at
|
|
||||||
// all in a name written entirely in kana.
|
|
||||||
if title.contains("オープニング") {
|
|
||||||
return Self::Opening;
|
|
||||||
}
|
|
||||||
if title.contains("エンディング") {
|
|
||||||
return Self::Ending;
|
|
||||||
}
|
|
||||||
if title.contains("予告") {
|
|
||||||
return Self::Preview;
|
|
||||||
}
|
|
||||||
|
|
||||||
let words = words(title);
|
|
||||||
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
|
||||||
let Some((head, rest)) = words.split_first() else {
|
|
||||||
return Self::Content;
|
|
||||||
};
|
|
||||||
let second = rest.first().copied().unwrap_or_default();
|
|
||||||
|
|
||||||
match (*head, second) {
|
|
||||||
// Two-word forms first: the second word is what tells `next
|
|
||||||
// episode` from `next`, and `end credits` from `end`.
|
|
||||||
("next", _) | ("nep", _) => Self::Preview,
|
|
||||||
("end" | "ending" | "closing", "credits") => Self::Ending,
|
|
||||||
("cast" | "staff", "roll" | "credits") => Self::Ending,
|
|
||||||
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
|
|
||||||
("ending" | "ed", "theme" | "song") => Self::Ending,
|
|
||||||
|
|
||||||
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
|
|
||||||
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
|
|
||||||
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
|
|
||||||
|
|
||||||
("intro", _) => Self::MaybeOpening,
|
|
||||||
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
|
|
||||||
_ => Self::Content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A chapter name as lowercase words, with any leading numbering dropped.
|
|
||||||
///
|
|
||||||
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
|
|
||||||
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
|
|
||||||
fn words(title: &str) -> Vec<String> {
|
|
||||||
let mut words: Vec<String> = title
|
|
||||||
.trim()
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
|
||||||
.filter(|word| !word.is_empty())
|
|
||||||
.map(|word| {
|
|
||||||
word.trim_end_matches(|c: char| c.is_ascii_digit())
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// A leading token that was *only* digits is numbering, not a name.
|
|
||||||
if words.first().is_some_and(String::is_empty) {
|
|
||||||
words.remove(0);
|
|
||||||
}
|
|
||||||
words.retain(|word| !word.is_empty());
|
|
||||||
words
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The role of every chapter in a file, with the file's own shape taken into
|
|
||||||
/// account.
|
|
||||||
///
|
|
||||||
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
|
|
||||||
/// cannot tell an opening called `Intro` from ten minutes of story called the
|
|
||||||
/// same thing; here the other chapters and the running time settle it:
|
|
||||||
///
|
|
||||||
/// * a chapter that merely *reads* like an opening is one only when the file
|
|
||||||
/// has no chapter that says so outright, it runs the length of a theme song,
|
|
||||||
/// and it is near the front;
|
|
||||||
/// * a chapter that reads like credits is the ending when it runs to about that
|
|
||||||
/// length, or when it sits in the last quarter of the file.
|
|
||||||
///
|
|
||||||
/// Both rules fail *towards content*: not offering a skip costs a button, and
|
|
||||||
/// skipping the viewer out of the story costs them the story.
|
|
||||||
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
|
|
||||||
let claims: Vec<Claim> = chapters
|
|
||||||
.iter()
|
|
||||||
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
|
|
||||||
.collect();
|
|
||||||
let has_real_opening = claims.contains(&Claim::Opening);
|
|
||||||
|
|
||||||
claims
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, claim)| {
|
|
||||||
let length = chapter_end(chapters, index, duration)
|
|
||||||
.map(|end| end - chapters[index].start)
|
|
||||||
.filter(|length| *length > 0.0);
|
|
||||||
let start = chapters[index].start;
|
|
||||||
|
|
||||||
match claim {
|
|
||||||
Claim::Opening => ChapterRole::Opening,
|
|
||||||
Claim::Ending => ChapterRole::Ending,
|
|
||||||
Claim::Preview => ChapterRole::Preview,
|
|
||||||
Claim::MaybeOpening
|
|
||||||
if !has_real_opening
|
|
||||||
&& start <= OPENING_BEFORE_SECONDS
|
|
||||||
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
|
|
||||||
{
|
|
||||||
ChapterRole::Opening
|
|
||||||
}
|
|
||||||
Claim::MaybeEnding
|
|
||||||
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|
|
||||||
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
|
|
||||||
{
|
|
||||||
ChapterRole::Ending
|
|
||||||
}
|
|
||||||
_ => ChapterRole::Content,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the run of chapters that ends the file — credits, a preview, and
|
|
||||||
/// nothing the viewer would miss — begins.
|
|
||||||
///
|
|
||||||
/// **The suffix, not the last chapter.** A release that puts a Part C after the
|
|
||||||
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
|
|
||||||
/// exactly once) must give up only the preview, or auto-advancing skips a scene
|
|
||||||
/// that matters. Walking back from the end and stopping at the first chapter
|
|
||||||
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
|
|
||||||
/// preview alone, while the ordinary `… ED · Preview` yields both.
|
|
||||||
///
|
|
||||||
/// `None` when the file ends on content, has no chapters, or is *entirely*
|
|
||||||
/// skippable — the last of which is a mis-tagged file, not an episode that is
|
|
||||||
/// all credits.
|
|
||||||
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
|
|
||||||
let first = roles
|
|
||||||
.iter()
|
|
||||||
.rposition(|role| !role.ends_the_episode())
|
|
||||||
.map(|last_content| last_content + 1)?;
|
|
||||||
if first >= chapters.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(chapters[first].start)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every chapter of the file mpv currently has open.
|
/// Every chapter of the file mpv currently has open.
|
||||||
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
||||||
@@ -293,211 +32,3 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the chapter starting at `index` ends: the next one's start, or the end
|
|
||||||
/// of the file.
|
|
||||||
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
|
|
||||||
chapters
|
|
||||||
.get(index + 1)
|
|
||||||
.map(|next| next.start)
|
|
||||||
.or(duration)
|
|
||||||
.filter(|end| *end > 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The chapter a position falls in.
|
|
||||||
///
|
|
||||||
/// Linear rather than a binary search: a file has a dozen chapters, and this is
|
|
||||||
/// called once a frame at most.
|
|
||||||
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
|
|
||||||
chapters
|
|
||||||
.iter()
|
|
||||||
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
|
|
||||||
Chapter {
|
|
||||||
index,
|
|
||||||
title: Some(title.to_string()),
|
|
||||||
start,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_names_releases_actually_use_are_recognised() {
|
|
||||||
for (name, claim) in [
|
|
||||||
("OP", Claim::Opening),
|
|
||||||
("Opening", Claim::Opening),
|
|
||||||
("OP1 - Again", Claim::Opening),
|
|
||||||
("02 - Opening", Claim::Opening),
|
|
||||||
("Opening Song", Claim::Opening),
|
|
||||||
("NCOP", Claim::Opening),
|
|
||||||
("オープニング", Claim::Opening),
|
|
||||||
("ED", Claim::Ending),
|
|
||||||
("Ending 2", Claim::Ending),
|
|
||||||
("End Credits", Claim::Ending),
|
|
||||||
("Cast Roll", Claim::Ending),
|
|
||||||
("Endcard", Claim::Ending),
|
|
||||||
("エンディング", Claim::Ending),
|
|
||||||
("Preview", Claim::Preview),
|
|
||||||
("Next Episode", Claim::Preview),
|
|
||||||
("Next Episode Preview", Claim::Preview),
|
|
||||||
("予告", Claim::Preview),
|
|
||||||
] {
|
|
||||||
assert_eq!(Claim::of(name), claim, "{name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
|
|
||||||
// The reason this matches words and not prefixes: skipping the viewer
|
|
||||||
// out of "Operation Briefing" would be a bug they cannot undo without
|
|
||||||
// finding the seek bar.
|
|
||||||
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
|
|
||||||
assert_eq!(Claim::of(name), Claim::Content, "{name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The names that cannot be settled by reading them.
|
|
||||||
#[test]
|
|
||||||
fn an_ambiguous_name_is_not_decided_by_itself() {
|
|
||||||
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
|
|
||||||
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
|
|
||||||
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
|
|
||||||
// And a chapter read in isolation never resolves to a skip.
|
|
||||||
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
|
|
||||||
/// minute episode that begins with a long `Intro` that is the story, and
|
|
||||||
/// ends with a cast scroll. Offering "skip opening" over the first ninety
|
|
||||||
/// seconds of *that* skips the beginning of the series.
|
|
||||||
#[test]
|
|
||||||
fn a_long_intro_is_the_episode_and_not_an_opening() {
|
|
||||||
let chapters = vec![
|
|
||||||
chapter(0, "Intro", 0.0),
|
|
||||||
chapter(1, "Part A", 700.0),
|
|
||||||
chapter(2, "Part B", 2400.0),
|
|
||||||
chapter(3, "Cast", 4680.0),
|
|
||||||
];
|
|
||||||
let roles = roles(&chapters, Some(4800.0));
|
|
||||||
assert_eq!(
|
|
||||||
roles[0],
|
|
||||||
ChapterRole::Content,
|
|
||||||
"eleven minutes is not a theme"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
roles[3],
|
|
||||||
ChapterRole::Ending,
|
|
||||||
"a cast roll at the end is one"
|
|
||||||
);
|
|
||||||
// Only the cast scroll is the tail; the episode is not skipped into.
|
|
||||||
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same name in the release that uses it for the theme song: ninety
|
|
||||||
/// seconds, near the front, and no other opening in the file.
|
|
||||||
#[test]
|
|
||||||
fn a_theme_length_intro_near_the_front_is_an_opening() {
|
|
||||||
let chapters = vec![
|
|
||||||
chapter(0, "Avant", 0.0),
|
|
||||||
chapter(1, "Intro", 90.0),
|
|
||||||
chapter(2, "Part A", 180.0),
|
|
||||||
];
|
|
||||||
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
|
|
||||||
|
|
||||||
// …but not when the file already says which chapter the opening is.
|
|
||||||
let chapters = vec![
|
|
||||||
chapter(0, "Intro", 0.0),
|
|
||||||
chapter(1, "OP", 90.0),
|
|
||||||
chapter(2, "Part A", 180.0),
|
|
||||||
];
|
|
||||||
let roles = roles(&chapters, Some(1440.0));
|
|
||||||
assert_eq!(roles[0], ChapterRole::Content);
|
|
||||||
assert_eq!(roles[1], ChapterRole::Opening);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ordinary shape: the credits and the trailer for next week are one
|
|
||||||
/// run at the end, and the countdown starts where the credits do.
|
|
||||||
#[test]
|
|
||||||
fn the_tail_is_the_whole_run_of_credits_and_preview() {
|
|
||||||
let chapters = vec![
|
|
||||||
chapter(0, "OP", 0.0),
|
|
||||||
chapter(1, "Part A", 90.0),
|
|
||||||
chapter(2, "Part B", 700.0),
|
|
||||||
chapter(3, "ED", 1320.0),
|
|
||||||
chapter(4, "Preview", 1410.0),
|
|
||||||
];
|
|
||||||
let roles = roles(&chapters, Some(1440.0));
|
|
||||||
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
|
|
||||||
/// ending, and the preview after that. Only the preview may be skipped —
|
|
||||||
/// treating everything from the ending on as tail would drop a scene.
|
|
||||||
#[test]
|
|
||||||
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
|
|
||||||
let chapters = vec![
|
|
||||||
chapter(0, "Part A", 0.0),
|
|
||||||
chapter(1, "Part B", 700.0),
|
|
||||||
chapter(2, "ED", 1260.0),
|
|
||||||
chapter(3, "Part C", 1350.0),
|
|
||||||
chapter(4, "Preview", 1400.0),
|
|
||||||
];
|
|
||||||
let roles = roles(&chapters, Some(1440.0));
|
|
||||||
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
|
|
||||||
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file that ends on the story has no tail at all, and one with no
|
|
||||||
/// chapters has nothing to read.
|
|
||||||
#[test]
|
|
||||||
fn a_file_that_ends_on_content_has_no_tail() {
|
|
||||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
|
||||||
let roles = roles(&chapters, Some(1440.0));
|
|
||||||
assert_eq!(credits_start(&chapters, &roles), None);
|
|
||||||
assert_eq!(credits_start(&[], &[]), None);
|
|
||||||
|
|
||||||
// Everything skippable is a mis-tagged file, not an episode that is
|
|
||||||
// entirely credits: auto-advancing out of it at second zero would be
|
|
||||||
// worse than doing nothing.
|
|
||||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
|
|
||||||
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
|
|
||||||
assert_eq!(credits_start(&chapters, &roles), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_position_lands_in_the_chapter_that_contains_it() {
|
|
||||||
let chapters = vec![
|
|
||||||
chapter(0, "Intro", 0.0),
|
|
||||||
chapter(1, "OP", 24.0),
|
|
||||||
chapter(2, "Part A", 114.0),
|
|
||||||
];
|
|
||||||
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
|
|
||||||
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
|
|
||||||
// Exactly on a boundary belongs to the chapter that starts there,
|
|
||||||
// which is what makes "skip" land *out* of the opening rather than at
|
|
||||||
// its last frame.
|
|
||||||
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
|
|
||||||
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
|
|
||||||
let chapters = vec![chapter(0, "OP", 24.0)];
|
|
||||||
assert_eq!(chapter_at(&chapters, 0.0), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_last_chapter_ends_where_the_file_does() {
|
|
||||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
|
||||||
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
|
|
||||||
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
|
|
||||||
// No duration yet and nothing after it: there is nowhere to skip to,
|
|
||||||
// and a button that seeks to zero would be worse than no button.
|
|
||||||
assert_eq!(chapter_end(&chapters, 1, None), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,4 +52,4 @@ pub use player::{
|
|||||||
};
|
};
|
||||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||||
pub use render::VideoRenderer;
|
pub use render::VideoRenderer;
|
||||||
pub use tracks::{Track, TrackKind, language_name};
|
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
||||||
@@ -94,6 +94,11 @@ pub enum PlayerEvent {
|
|||||||
/// Output volume, 0–100.
|
/// Output volume, 0–100.
|
||||||
Volume(f64),
|
Volume(f64),
|
||||||
Muted(bool),
|
Muted(bool),
|
||||||
|
/// How far into the file the demuxer has read ahead, in seconds from the
|
||||||
|
/// start — the end of what can play without another fetch.
|
||||||
|
Buffered(f64),
|
||||||
|
/// Whether playback is held waiting for data.
|
||||||
|
Buffering(bool),
|
||||||
/// What the file contains and what is playing of it.
|
/// What the file contains and what is playing of it.
|
||||||
///
|
///
|
||||||
/// Not an mpv event: mpv reports a track list as a node property, and this
|
/// Not an mpv event: mpv reports a track list as a node property, and this
|
||||||
@@ -308,6 +313,10 @@ impl Player {
|
|||||||
// it clamps a restored value to.
|
// it clamps a restored value to.
|
||||||
self.mpv.observe_property("volume", Format::Double, 0)?;
|
self.mpv.observe_property("volume", Format::Double, 0)?;
|
||||||
self.mpv.observe_property("mute", Format::Flag, 0)?;
|
self.mpv.observe_property("mute", Format::Flag, 0)?;
|
||||||
|
self.mpv
|
||||||
|
.observe_property("demuxer-cache-time", Format::Double, 0)?;
|
||||||
|
self.mpv
|
||||||
|
.observe_property("paused-for-cache", Format::Flag, 0)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +458,32 @@ impl Player {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where subtitles sit, as a percentage of the window's height from the
|
||||||
|
/// top: 100 is mpv's usual place along the bottom edge.
|
||||||
|
///
|
||||||
|
/// `sub-pos` rather than a margin, because it moves styled (ASS) subtitles
|
||||||
|
/// too — and those are most of what an anime library carries.
|
||||||
|
pub fn set_subtitle_position(&self, percent: f64) -> Result<()> {
|
||||||
|
self.mpv
|
||||||
|
.set_property("sub-pos", percent.clamp(0.0, 150.0).round() as i64)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
|
||||||
|
/// default and what keeps a voice a voice at 1.5×.
|
||||||
|
pub fn set_speed(&self, speed: f64) -> Result<()> {
|
||||||
|
self.mpv.set_property("speed", speed.clamp(0.25, 4.0))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the current frame, without subtitles or anything drawn over it,
|
||||||
|
/// to `path`. The format follows the extension.
|
||||||
|
pub fn screenshot(&self, path: &std::path::Path) -> Result<()> {
|
||||||
|
let path = path.to_string_lossy();
|
||||||
|
self.mpv.command("screenshot-to-file", &[&path, "video"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<()> {
|
pub fn stop(&self) -> Result<()> {
|
||||||
self.mpv.command("stop", &[])?;
|
self.mpv.command("stop", &[])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -505,6 +540,8 @@ fn translate(event: Event<'_>) -> PlayerEvent {
|
|||||||
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
|
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
|
||||||
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
|
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
|
||||||
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value),
|
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value),
|
||||||
|
("demuxer-cache-time", PropertyData::Double(value)) => PlayerEvent::Buffered(value),
|
||||||
|
("paused-for-cache", PropertyData::Flag(value)) => PlayerEvent::Buffering(value),
|
||||||
_ => PlayerEvent::Other,
|
_ => PlayerEvent::Other,
|
||||||
},
|
},
|
||||||
_ => PlayerEvent::Other,
|
_ => PlayerEvent::Other,
|
||||||
|
|||||||
@@ -107,6 +107,56 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Track> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The track of `kind` that matches a choice remembered from another file.
|
||||||
|
///
|
||||||
|
/// A title outranks a language: two English subtitle tracks — "Signs & Songs"
|
||||||
|
/// and "Full Subtitles" — share a language and differ only by name, and the
|
||||||
|
/// name is what was picked. A title in the wrong language is not a match, since
|
||||||
|
/// "Full Subtitles" in German is not what someone reading English chose. Without
|
||||||
|
/// a title match, a track in the language wins, the container's default first.
|
||||||
|
/// `None` when nothing matches, which leaves the current selection standing.
|
||||||
|
pub fn pick_track(
|
||||||
|
tracks: &[Track],
|
||||||
|
kind: TrackKind,
|
||||||
|
language: Option<&str>,
|
||||||
|
title: Option<&str>,
|
||||||
|
) -> Option<i64> {
|
||||||
|
let in_language = |track: &Track| match (language, track.language.as_deref()) {
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(wanted), Some(have)) => same_language(wanted, have),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
};
|
||||||
|
let of_kind = || tracks.iter().filter(move |track| track.kind == kind);
|
||||||
|
|
||||||
|
if let Some(title) = title
|
||||||
|
&& let Some(track) = of_kind().find(|track| {
|
||||||
|
in_language(track)
|
||||||
|
&& track
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|have| have.eq_ignore_ascii_case(title))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Some(track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
language?;
|
||||||
|
let mut matching = of_kind().filter(|track| in_language(track));
|
||||||
|
let first = matching.next()?;
|
||||||
|
Some(
|
||||||
|
std::iter::once(first)
|
||||||
|
.chain(matching)
|
||||||
|
.find(|track| track.default)
|
||||||
|
.unwrap_or(first)
|
||||||
|
.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||||
|
fn same_language(a: &str, b: &str) -> bool {
|
||||||
|
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||||
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
||||||
let id: i64 = mpv
|
let id: i64 = mpv
|
||||||
@@ -242,6 +292,87 @@ mod tests {
|
|||||||
assert_eq!(language_name("mis"), "mis");
|
assert_eq!(language_name("mis"), "mis");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
||||||
|
Track {
|
||||||
|
language: language.map(Into::into),
|
||||||
|
title: title.map(Into::into),
|
||||||
|
..track(kind, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remembered_title_picks_between_tracks_of_one_language() {
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Subtitle, 1, Some("eng"), Some("Signs & Songs")),
|
||||||
|
named(TrackKind::Subtitle, 2, Some("eng"), Some("Full Subtitles")),
|
||||||
|
];
|
||||||
|
let mut signs = tracks[0].clone();
|
||||||
|
signs.default = true;
|
||||||
|
let tracks = [signs, tracks[1].clone()];
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(
|
||||||
|
&tracks,
|
||||||
|
TrackKind::Subtitle,
|
||||||
|
Some("eng"),
|
||||||
|
Some("full subtitles")
|
||||||
|
),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_in_another_language_is_not_the_one_that_was_chosen() {
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Subtitle, 1, Some("ger"), Some("Full Subtitles")),
|
||||||
|
named(TrackKind::Subtitle, 2, Some("en"), Some("Dialogue")),
|
||||||
|
];
|
||||||
|
// Falls back to the language, and "en" is the same language as "eng".
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(
|
||||||
|
&tracks,
|
||||||
|
TrackKind::Subtitle,
|
||||||
|
Some("eng"),
|
||||||
|
Some("Full Subtitles")
|
||||||
|
),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untagged_track_is_found_by_its_title_alone() {
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Audio, 1, None, Some("Stereo")),
|
||||||
|
named(TrackKind::Audio, 2, None, Some("Commentary")),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, None, Some("Commentary")),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_language_match_prefers_the_default_track_and_nothing_matching_is_none() {
|
||||||
|
let mut second = named(TrackKind::Audio, 2, Some("jpn"), None);
|
||||||
|
second.default = true;
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Audio, 1, Some("jpn"), Some("Commentary")),
|
||||||
|
second,
|
||||||
|
named(TrackKind::Subtitle, 3, Some("jpn"), None),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, Some("ja"), None),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, Some("fre"), None),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, None, Some("Stereo")),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_kinds_the_player_can_select_are_tracks() {
|
fn only_the_kinds_the_player_can_select_are_tracks() {
|
||||||
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
|
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
|
||||||
|
|||||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user