Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb3a97b408 | ||
|
|
04fc1f04fe | ||
|
|
aa0f4b1250 | ||
|
|
c261f88c3f | ||
|
|
bf3fa19c70 | ||
|
|
01eee78317 | ||
|
|
2546bab0a6 | ||
|
|
8d795c29e6 | ||
|
|
47c482498b | ||
|
|
31bdd75559 | ||
|
|
d43da778b6 | ||
|
|
abb3e3aaf8 | ||
|
|
5b931111b3 | ||
|
|
5c0519a226 | ||
|
|
5c15f45ace | ||
|
|
eb67df0aa0 | ||
|
|
fdf6b0359b | ||
|
|
6ddbcea429 | ||
|
|
a79e41aa8a | ||
|
|
89e55aac12 | ||
|
|
0f96cc4514 | ||
|
|
9d72acce88 | ||
|
|
d154002109 | ||
|
|
690455f7dd | ||
|
|
01c252b794 | ||
|
|
6d1943f2bd | ||
|
|
5dfb717235 | ||
|
|
3ff55a9af9 | ||
|
|
33da1563ee | ||
|
|
7994097a25 | ||
|
|
77a926cbf5 | ||
|
|
603cc0c1a9 | ||
|
|
5807493a4a | ||
|
|
a306d02cf1 | ||
|
|
c6b0457c4d | ||
|
|
c5490ab3da | ||
|
|
9e53a9bbdb | ||
|
|
ce02bbc35a | ||
|
|
0b9a9dab50 | ||
|
|
73973841c2 | ||
|
|
2f3be1d193 | ||
|
|
8e42906a7f |
No files matched your search
@@ -11,6 +11,110 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"serde",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_macos"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.38.0",
|
||||
"hashbrown 0.16.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
"async-channel",
|
||||
"async-executor",
|
||||
"async-task",
|
||||
"atspi",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"zbus 5.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
"hashbrown 0.16.1",
|
||||
"static_assertions",
|
||||
"windows 0.62.2",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_winit"
|
||||
version = "0.32.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_macos",
|
||||
"accesskit_unix",
|
||||
"accesskit_windows",
|
||||
"raw-window-handle",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -360,6 +464,20 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -482,6 +600,43 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atspi"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
|
||||
dependencies = [
|
||||
"atspi-common",
|
||||
"atspi-proxies",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atspi-common"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zbus 5.19.0",
|
||||
"zbus-lockstep",
|
||||
"zbus-lockstep-macros",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atspi-proxies"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
|
||||
dependencies = [
|
||||
"atspi-common",
|
||||
"serde",
|
||||
"zbus 5.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -2017,6 +2172,7 @@ version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -5122,7 +5278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"futures",
|
||||
@@ -5142,7 +5298,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5176,7 +5332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5195,7 +5351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -5215,7 +5371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -5228,7 +5384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
@@ -5243,7 +5399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
@@ -5870,7 +6026,7 @@ dependencies = [
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"zbus",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7229,6 +7385,11 @@ name = "uuid"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
@@ -8544,9 +8705,68 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros 5.19.0",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-lockstep"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
|
||||
dependencies = [
|
||||
"zbus_xml",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-lockstep-macros"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zbus-lockstep",
|
||||
"zbus_xml",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8559,7 +8779,22 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
"zvariant_utils 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8570,7 +8805,39 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_xml"
|
||||
version = "5.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8704,7 +8971,22 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive 5.15.0",
|
||||
"zvariant_utils 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8717,7 +8999,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"zvariant_utils 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8730,3 +9025,16 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.3.0"
|
||||
version = "1.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -73,8 +73,10 @@ keyring = { version = "3" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
||||
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
||||
# eframe is drawing into.
|
||||
# eframe is drawing into. `accesskit` hands the widget tree to the platform's
|
||||
# screen reader (AT-SPI, UI Automation, NSAccessibility).
|
||||
eframe = { version = "0.35", default-features = false, features = [
|
||||
"accesskit",
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"wayland",
|
||||
|
||||
@@ -26,6 +26,23 @@ indicator while a crawl runs, but not the crawl's progress.
|
||||
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||
tests) passes, and Android lint runs in CI.
|
||||
|
||||
A source review on 2026-10-12 found these still short of the text below:
|
||||
|
||||
- **Title page.** The backdrop scrolls away with the back button instead of
|
||||
collapsing to a pinned bar, and the icon row has no mark-watched action.
|
||||
- **Episode rows** show the file size where runtime was asked for.
|
||||
- **Player episode list** is a bottom sheet in landscape too, not a side
|
||||
panel.
|
||||
- **Storage** has a usage bar for the streaming cache only; offline episodes
|
||||
have none.
|
||||
- **Metadata language** is still a free-text field.
|
||||
- **Touch targets.** The player's icons are 40 dp, under the 48 dp floor.
|
||||
- **Screenshots** do not cover the player, and only the library is taken at
|
||||
font scale 1.5.
|
||||
|
||||
Share rows now say how many files the last crawl found, when it ran and why it
|
||||
failed (section 2, Shares), as of the catalog's `share_crawls` table.
|
||||
|
||||
## 0. CI (done)
|
||||
|
||||
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -202,7 +202,7 @@ struct PlaybackState {
|
||||
/// 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.
|
||||
/// Zero until then.
|
||||
double video_width = 0.0;
|
||||
double video_height = 0.0;
|
||||
/// Which loaded file this state describes. Bumped by every load, so a
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
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.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
@@ -37,18 +25,13 @@ import io.narl.protonstream.sync.WatchSyncWorker
|
||||
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() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
private val inPictureInPicture = mutableStateOf(false)
|
||||
|
||||
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||
@@ -69,30 +52,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before anything draws. The palette decides the window's own
|
||||
@@ -116,34 +79,16 @@ class MainActivity : ComponentActivity() {
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
pictureInPictureControls,
|
||||
IntentFilter(ACTION_PIP_CONTROL),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
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 {
|
||||
ProtonStreamTheme {
|
||||
ProtonStreamApp(
|
||||
playerHost.value,
|
||||
inPictureInPicture.value,
|
||||
shareLink = incomingShareLink.value,
|
||||
onShareLinkTaken = { incomingShareLink.value = null },
|
||||
)
|
||||
@@ -178,27 +123,7 @@ class MainActivity : ComponentActivity() {
|
||||
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||
}
|
||||
|
||||
override fun 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) {
|
||||
runCatching {
|
||||
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() {
|
||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
@@ -213,87 +138,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!isInPictureInPictureMode &&
|
||||
!SettingsStore(this).backgroundAudio
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,15 @@ package io.narl.protonstream
|
||||
|
||||
import android.app.Application
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
|
||||
class ProtonStreamApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NativeRuntime.initialize(this)
|
||||
// SharedPreferences reads its file on first use, and the first use was
|
||||
// on the main thread — in the playback service's start callback among
|
||||
// others. Read here, off it, and every later read is from memory.
|
||||
Thread({ SettingsStore.preload(this) }, "settings-preload").start()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
@@ -25,12 +29,27 @@ class OfflineDownloadWorker(
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(null))
|
||||
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 linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
val store = DownloadStateStore(applicationContext)
|
||||
setForeground(createForegroundInfo(label, null))
|
||||
postSummary(store)
|
||||
try {
|
||||
return transfer(store, shareId, volumeId, linkId, label)
|
||||
} finally {
|
||||
postSummary(store)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun transfer(
|
||||
store: DownloadStateStore,
|
||||
shareId: String,
|
||||
volumeId: String,
|
||||
linkId: String,
|
||||
label: String,
|
||||
): Result {
|
||||
// Queued, not running: the engine lets two downloads transfer at once
|
||||
// and holds the rest back, and only the first progress report means
|
||||
// this one has a slot.
|
||||
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
|
||||
reportedAt = now
|
||||
reportedPercent = percent
|
||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||
setForegroundAsync(createForegroundInfo(percent))
|
||||
setForegroundAsync(createForegroundInfo(label, percent))
|
||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||
// this read-modify-write used to put RUNNING back over the
|
||||
// viewer's PAUSED and then report the download as cancelled.
|
||||
@@ -120,15 +139,20 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
/** [progress] is a percentage, or null while waiting for a download slot. */
|
||||
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
|
||||
/**
|
||||
* This episode's line, under [GROUP]: a season is fourteen workers, and
|
||||
* fourteen loose notifications saying the same thing named nothing.
|
||||
* [progress] is a percentage, or null while waiting for a download slot.
|
||||
*/
|
||||
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(
|
||||
if (progress == null) "Waiting to download" else "Making episode available offline",
|
||||
)
|
||||
.setContentTitle(label)
|
||||
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.build()
|
||||
return ForegroundInfo(
|
||||
stableNotificationId(id),
|
||||
@@ -137,6 +161,36 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line the group folds into — "Downloading 12 episodes" — or none
|
||||
* once nothing is left to download. Posted as each worker starts and
|
||||
* ends rather than on progress, which each episode's own line carries.
|
||||
*/
|
||||
private fun postSummary(store: DownloadStateStore) {
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
val left = store.records().count {
|
||||
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
|
||||
}
|
||||
if (left == 0) {
|
||||
manager.cancel(SUMMARY_ID)
|
||||
return
|
||||
}
|
||||
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed) return
|
||||
manager.notify(
|
||||
SUMMARY_ID,
|
||||
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.setGroupSummary(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||
|
||||
companion object {
|
||||
@@ -149,6 +203,10 @@ class OfflineDownloadWorker(
|
||||
const val KEY_TOTAL = "total"
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val CHANNEL_ID = "offline-downloads"
|
||||
private const val GROUP = "offline-downloads"
|
||||
|
||||
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
|
||||
internal const val SUMMARY_ID = 1
|
||||
|
||||
/** Shortest gap between two identical-looking progress reports. */
|
||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||
@@ -186,4 +244,4 @@ internal class TransferRate {
|
||||
}
|
||||
|
||||
internal fun stableNotificationId(workId: UUID): Int =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
|
||||
@@ -85,9 +85,9 @@ data class MpvPlaybackState(
|
||||
/**
|
||||
* 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.
|
||||
* The media session and its notification have to name the episode, and
|
||||
* neither can reach the composition that knows it. The player publishes this
|
||||
* once per episode and they read it.
|
||||
*/
|
||||
data class NowPlaying(
|
||||
val media: String,
|
||||
@@ -134,8 +134,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
/**
|
||||
* 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
|
||||
* The player owns which episode is open, so the notification and the lock
|
||||
* screen cannot walk the playlist themselves. Both
|
||||
* run on the main thread, which is where the player's state lives.
|
||||
*/
|
||||
var onSkipPrevious: (() -> Unit)? = null
|
||||
@@ -245,7 +245,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
*/
|
||||
fun beginTransition() { mutableAdvancing.value = true }
|
||||
|
||||
/** Names the open episode for the media session, its notification and PiP. */
|
||||
/** Names the open episode for the media session and its notification. */
|
||||
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
|
||||
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.loadArtwork
|
||||
import io.narl.protonstream.ui.loadThumbnail
|
||||
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||
class PlaybackService : Service() {
|
||||
inner class PlaybackBinder : Binder() {
|
||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
||||
private var notified: NotificationShape? = null
|
||||
/** The pending teardown, held so an episode transition can call it off. */
|
||||
private var retire: Job? = null
|
||||
/** The open episode's latest reading, which [track] saves. */
|
||||
private var lastReading: MpvPlaybackState? = null
|
||||
private var lastSave = 0L
|
||||
/** The episode's artwork, and the source key it was fetched for. */
|
||||
private var artwork: Pair<String, Bitmap>? = null
|
||||
private val binder = PlaybackBinder()
|
||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it) }
|
||||
onStateChanged = { updateSession(it); track(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Swiped away from Recents. With background audio on, this service would
|
||||
* otherwise play on with no screen to return to and no autoplay, so it
|
||||
* saves where the episode got to and stops.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
lastReading?.let { reading ->
|
||||
// Bounded and on this thread: the process may go as soon as the
|
||||
// service stops, and a save launched into its scope would go with it.
|
||||
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||
}
|
||||
host?.stop()
|
||||
retirePlayback()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the resume position from here rather than from the player screen.
|
||||
*
|
||||
* This service outlives the activity whenever background audio is on, and a
|
||||
* position the screen alone kept stopped being saved the moment the screen
|
||||
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||
* change saves the outgoing episode's last reading before the new one's.
|
||||
*/
|
||||
private fun track(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
if (state.duration <= 0.0) return
|
||||
val previous = lastReading
|
||||
lastReading = state
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val due = when {
|
||||
previous != null && previous.media != media -> {
|
||||
scope.launch(Dispatchers.IO) { save(previous) }
|
||||
true
|
||||
}
|
||||
state.ended -> previous?.ended != true
|
||||
state.paused -> previous?.paused != true
|
||||
else -> now - lastSave >= SAVE_EVERY_MS
|
||||
}
|
||||
if (due) {
|
||||
lastSave = now
|
||||
scope.launch(Dispatchers.IO) { save(state) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
// Played to the end, or near enough that what is left is credits. A
|
||||
// failure or a stop also "ends", and recording either as watched would
|
||||
// retire an episode nobody saw.
|
||||
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||
runCatching {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
media.substringBeforeLast(':'),
|
||||
media.substringAfterLast(':'),
|
||||
state.position.coerceIn(0.0, state.duration),
|
||||
state.duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
refreshSession(state, host?.nowPlaying?.value)
|
||||
if (state.ended) scheduleRetire() else cancelRetire()
|
||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
||||
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
|
||||
private const val SAVE_EVERY_MS = 5_000L
|
||||
/** How long a swipe from Recents may hold the main thread to save. */
|
||||
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||
|
||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -12,13 +13,13 @@ 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.widthIn
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -38,6 +39,7 @@ 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.MoreVert
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
@@ -47,6 +49,8 @@ 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.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -58,9 +62,15 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -70,6 +80,7 @@ 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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -106,6 +117,7 @@ import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TrackPreferencesRecord
|
||||
import uniffi.pstr_android.skipOffer
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** How long the controls stay up after a tap. */
|
||||
@@ -114,8 +126,17 @@ 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
|
||||
|
||||
/** How far a double tap on either side of the picture seeks. */
|
||||
private const val DOUBLE_TAP_SECONDS = 10
|
||||
|
||||
/** How long the double-tap seek mark stays up. */
|
||||
private const val NUDGE_MS = 700L
|
||||
|
||||
/** How close to the end the next episode's opening blocks are fetched. */
|
||||
private const val WARM_NEXT_SECONDS = 120.0
|
||||
|
||||
/** Past this much of an episode it counts as watched. */
|
||||
private const val WATCHED_FRACTION = 0.9
|
||||
internal const val WATCHED_FRACTION = 0.9
|
||||
|
||||
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||
private const val MAX_VOLUME = 100f
|
||||
@@ -126,23 +147,22 @@ private const val MAX_VOLUME = 100f
|
||||
* 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
|
||||
private val VOLUME_SLIDER_MIN_WIDTH = 720.dp
|
||||
|
||||
/** Narrower than this, the pickers past subtitles go behind "More". */
|
||||
private val INLINE_CONTROLS_MIN_WIDTH = 560.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.
|
||||
* Not a dialog: a dialog window has its own insets and cannot be told to draw
|
||||
* into the display cutout. 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(
|
||||
@@ -150,7 +170,6 @@ fun PlayerScreen(
|
||||
episodes: List<EpisodeRecord>,
|
||||
index: Int,
|
||||
host: LibmpvHost?,
|
||||
inPictureInPicture: Boolean,
|
||||
minimized: Boolean,
|
||||
onIndexChange: (Int) -> Unit,
|
||||
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
||||
@@ -189,8 +208,16 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
val key = mediaKey(episode)
|
||||
val session = remember(key) { RustStreamSession(episode) }
|
||||
// Retry opens the episode again from scratch: a new stream, through the
|
||||
// bridge's session refresh, from where the failed one stopped.
|
||||
var attempt by remember(key) { mutableIntStateOf(0) }
|
||||
var retryFrom by remember(key) { mutableStateOf<Double?>(null) }
|
||||
val session = remember(key, attempt) { RustStreamSession(episode) }
|
||||
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
||||
// From the click until mpv has the file: the share session, the revision
|
||||
// and its block list all come first, and on a slow link that is seconds of
|
||||
// black screen unless something says so.
|
||||
var opening by remember(key, attempt) { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(session, host) {
|
||||
runCatching {
|
||||
@@ -209,7 +236,7 @@ fun PlayerScreen(
|
||||
size = session.size(),
|
||||
// A finished episode restarts rather than resuming three
|
||||
// seconds from its own credits.
|
||||
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||
startPosition = retryFrom ?: watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
||||
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
||||
subtitles = show?.subtitles ?: global.subtitles,
|
||||
@@ -229,6 +256,7 @@ fun PlayerScreen(
|
||||
nativeHost.setSpeed(startup.speed)
|
||||
session.transferToPlayer()
|
||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||
opening = false
|
||||
}
|
||||
|
||||
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||
@@ -266,12 +294,8 @@ fun PlayerScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(key) {
|
||||
while (true) {
|
||||
delay(5_000)
|
||||
save()
|
||||
}
|
||||
}
|
||||
// The five-second save is PlaybackService's, which outlives this screen;
|
||||
// these are the moments only the screen sees.
|
||||
DisposableEffect(key) { onDispose { save() } }
|
||||
|
||||
val next = episodes.getOrNull(index + 1)
|
||||
@@ -294,9 +318,8 @@ fun PlayerScreen(
|
||||
onIndexChange(target)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// What the media notification and the lock screen show. Neither can reach
|
||||
// this composition, and both have to name the episode rather than the app.
|
||||
LaunchedEffect(key, index, episodes.size, title.key) {
|
||||
nativeHost.publish(
|
||||
NowPlaying(
|
||||
@@ -324,8 +347,10 @@ fun PlayerScreen(
|
||||
|
||||
// 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.
|
||||
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||
var stay by remember(key) { mutableStateOf(false) }
|
||||
LaunchedEffect(state.endReason, state.media) {
|
||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
|
||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && 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.
|
||||
@@ -334,14 +359,42 @@ fun PlayerScreen(
|
||||
playbackError = "This episode could not be played."
|
||||
}
|
||||
}
|
||||
// A stall that does not clear is a dropped connection far more often than
|
||||
// a slow one, and "Buffering…" for ever offers no way out of it.
|
||||
LaunchedEffect(state.buffering, key, attempt) {
|
||||
if (state.buffering) {
|
||||
delay(STALL_LIMIT_MS)
|
||||
playbackError = "Still loading. The connection may have dropped."
|
||||
}
|
||||
}
|
||||
// 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()) {
|
||||
// Whether mpv's readings are this episode's. Not while the next one opens:
|
||||
// the last one's credits play on until it is loaded, and read against the
|
||||
// new index they offered the episode after the next as up next.
|
||||
val playingThis = state.media == key
|
||||
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
|
||||
if (!playingThis) return@remember null
|
||||
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
||||
}
|
||||
// Near the end, fetch the next episode's opening blocks, so autoplay or
|
||||
// "Play now" starts from the cache. The last two minutes or the credits,
|
||||
// whichever comes first: late enough that stopping after one episode has
|
||||
// not cost a download, early enough to matter.
|
||||
val nearEnd = playingThis && state.duration > 0.0 && (
|
||||
state.duration - state.position < WARM_NEXT_SECONDS ||
|
||||
chapters.creditsStart?.let { state.position >= it } == true
|
||||
)
|
||||
LaunchedEffect(key, nearEnd) {
|
||||
val upcoming = next ?: return@LaunchedEffect
|
||||
if (!nearEnd) return@LaunchedEffect
|
||||
runCatching {
|
||||
NativeRuntime.engine().warmEpisode(upcoming.shareId, upcoming.volumeId, upcoming.linkId)
|
||||
}
|
||||
}
|
||||
// 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>() }
|
||||
@@ -352,31 +405,71 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
var landscapeLocked by remember { mutableStateOf(false) }
|
||||
// Set by the rotation button: from then on the viewer decides.
|
||||
var rotationChosen by remember { mutableStateOf(false) }
|
||||
// A wide picture fills a phone only on its side, so it turns there by
|
||||
// itself, once per episode, until the viewer picks for themselves.
|
||||
LaunchedEffect(key, playingThis, state.videoWidth > state.videoHeight) {
|
||||
if (!playingThis || rotationChosen || landscapeLocked) return@LaunchedEffect
|
||||
if (state.videoHeight > 0.0 && state.videoWidth > state.videoHeight) {
|
||||
landscapeLocked = activity?.lockLandscape() ?: false
|
||||
}
|
||||
}
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(showControls, state.paused) {
|
||||
if (showControls && !state.paused) {
|
||||
// TalkBack moves between controls by swiping, and a control that fades
|
||||
// four seconds after it is reached cannot be found again.
|
||||
val exploring = rememberTouchExploration()
|
||||
LaunchedEffect(exploring) { if (exploring) showControls = true }
|
||||
LaunchedEffect(showControls, state.paused, exploring) {
|
||||
if (showControls && !state.paused && !exploring) {
|
||||
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 }
|
||||
|
||||
// Which side was double-tapped, shown for a moment so the seek is seen
|
||||
// to have happened; the count makes a second tap on one side show again.
|
||||
var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
LaunchedEffect(nudge) {
|
||||
if (nudge != null) {
|
||||
delay(NUDGE_MS)
|
||||
nudge = null
|
||||
}
|
||||
}
|
||||
val current by rememberUpdatedState(state)
|
||||
if (minimized) return
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
enabled = overlays,
|
||||
onClick = { showControls = !showControls },
|
||||
),
|
||||
// Double-tap a side to seek ten seconds, as every phone video
|
||||
// player has taught; the middle plays or pauses. A single tap
|
||||
// waits out the double-tap timeout, which is the price of both.
|
||||
.pointerInput(host) {
|
||||
detectTapGestures(
|
||||
onTap = { showControls = !showControls },
|
||||
onDoubleTap = { offset ->
|
||||
val third = size.width / 3f
|
||||
val seconds = when {
|
||||
offset.x < third -> -DOUBLE_TAP_SECONDS
|
||||
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
|
||||
else -> 0
|
||||
}
|
||||
if (seconds == 0) {
|
||||
host.setPaused(!current.paused)
|
||||
} else {
|
||||
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.semantics {
|
||||
onClick(label = if (showControls) "Hide controls" else "Show controls") {
|
||||
showControls = !showControls
|
||||
true
|
||||
}
|
||||
},
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -393,7 +486,7 @@ fun PlayerScreen(
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = overlays && showControls,
|
||||
visible = showControls,
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
@@ -450,7 +543,7 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = overlays && showControls,
|
||||
visible = showControls,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
@@ -468,7 +561,10 @@ fun PlayerScreen(
|
||||
episodes = episodes,
|
||||
playing = index,
|
||||
onEpisode = { jumpTo(it) },
|
||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
||||
onRotate = {
|
||||
rotationChosen = true
|
||||
landscapeLocked = activity?.toggleLandscape() ?: false
|
||||
},
|
||||
landscapeLocked = landscapeLocked,
|
||||
)
|
||||
}
|
||||
@@ -476,7 +572,7 @@ fun PlayerScreen(
|
||||
// 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 ->
|
||||
offer?.takeIf { !autoSkip }?.let { skip ->
|
||||
TonalButton(
|
||||
onClick = { nativeHost.seek(skip.target) },
|
||||
modifier = Modifier
|
||||
@@ -486,21 +582,59 @@ fun PlayerScreen(
|
||||
) { Text(skip.label) }
|
||||
}
|
||||
|
||||
nudge?.let { (seconds, _) ->
|
||||
Box(
|
||||
Modifier
|
||||
.align(if (seconds < 0) Alignment.CenterStart else Alignment.CenterEnd)
|
||||
.padding(horizontal = 48.dp)
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(if (seconds < 0) "−${-seconds} s" else "+$seconds s", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
// A file with no chapters has no credits to count over, so the card
|
||||
// takes its last seconds instead: without it the next episode cut in
|
||||
// with no warning and no way to stay. Counted on the file's own clock,
|
||||
// so it reaches zero at the end of file, which is what advances.
|
||||
val tailLeft = (state.duration - state.position).takeIf {
|
||||
chapters.creditsStart == null && state.duration > UP_NEXT_SECONDS * 6 && it <= UP_NEXT_SECONDS
|
||||
}
|
||||
UpNextCard(
|
||||
next = next,
|
||||
visible = overlays && autoplay && !state.paused &&
|
||||
chapters.creditsStart?.let { state.position >= it } == true,
|
||||
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
||||
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||
onPlayNow = { advance() },
|
||||
onStay = { stay = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||
)
|
||||
|
||||
// Played to the end with nothing starting after it: the last episode,
|
||||
// autoplay off, or the viewer stayed. A black screen said none of that.
|
||||
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
|
||||
EndCard(
|
||||
finished = episode.label,
|
||||
show = title.displayName,
|
||||
next = next,
|
||||
onPlayNext = { advance() },
|
||||
// mpv unloads a file at its end, so this opens it again.
|
||||
onReplay = { retryFrom = 0.0; attempt++ },
|
||||
onClose = onClose,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if ((opening || state.stalled) && playbackError == null) {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
@@ -512,10 +646,13 @@ fun PlayerScreen(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
if (state.buffering) {
|
||||
if (opening || state.buffering) {
|
||||
Text(
|
||||
state.cachePercent.takeIf { it > 0.0 }
|
||||
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
||||
when {
|
||||
opening -> "Opening ${episode.label}…"
|
||||
state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%"
|
||||
else -> "Buffering…"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
@@ -536,14 +673,29 @@ fun PlayerScreen(
|
||||
// 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)
|
||||
Row {
|
||||
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
||||
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
QuietButton(
|
||||
onClick = {
|
||||
retryFrom = state.position.takeIf { state.media == key && it > 0.0 } ?: retryFrom
|
||||
playbackError = null
|
||||
nativeHost.clearProblem()
|
||||
attempt++
|
||||
},
|
||||
) {
|
||||
Text("Retry", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a stall lasts before the player offers to open the episode again. */
|
||||
private const val STALL_LIMIT_MS = 20_000L
|
||||
|
||||
private data class Startup(
|
||||
val nativeHandle: ULong,
|
||||
val size: ULong,
|
||||
@@ -588,6 +740,26 @@ private fun ImmersiveWindow(activity: Activity?) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock to landscape. Reports whether it is. */
|
||||
private fun Activity.lockLandscape(): Boolean {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether TalkBack, or anything else that explores by touch, is on. */
|
||||
@Composable
|
||||
private fun rememberTouchExploration(): Boolean {
|
||||
val context = LocalContext.current
|
||||
val manager = remember(context) { context.getSystemService(AccessibilityManager::class.java) }
|
||||
var exploring by remember { mutableStateOf(manager?.isTouchExplorationEnabled == true) }
|
||||
DisposableEffect(manager) {
|
||||
val listener = AccessibilityManager.TouchExplorationStateChangeListener { exploring = it }
|
||||
manager?.addTouchExplorationStateChangeListener(listener)
|
||||
onDispose { manager?.removeTouchExplorationStateChangeListener(listener) }
|
||||
}
|
||||
return exploring
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -634,8 +806,15 @@ private fun PlayerControls(
|
||||
}
|
||||
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
||||
val density = LocalDensity.current
|
||||
val roomForVolume = with(density) {
|
||||
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
|
||||
val width = with(density) { LocalWindowInfo.current.containerSize.width.toDp() }
|
||||
val roomForVolume = width >= VOLUME_SLIDER_MIN_WIDTH
|
||||
// A phone in portrait has room for the transport and two more buttons at
|
||||
// a thumb's size. The rest go behind "More" there rather than off the edge.
|
||||
val roomForAll = width >= INLINE_CONTROLS_MIN_WIDTH
|
||||
val toggleMute = {
|
||||
host.setMuted(!state.muted)
|
||||
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
||||
Unit
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -697,45 +876,80 @@ private fun PlayerControls(
|
||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (episodes.size > 1) {
|
||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||
}
|
||||
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),
|
||||
if (!roomForAll) {
|
||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||
var more by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
|
||||
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
|
||||
val entries = buildList {
|
||||
if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
|
||||
if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
|
||||
add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
|
||||
add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
|
||||
add(
|
||||
Triple(
|
||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
if (state.muted) "Unmute" else "Mute",
|
||||
toggleMute,
|
||||
),
|
||||
)
|
||||
add(
|
||||
Triple(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onRotate,
|
||||
),
|
||||
)
|
||||
}
|
||||
entries.forEach { (icon, label, action) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
leadingIcon = { Icon(icon, contentDescription = null) },
|
||||
onClick = { more = false; action() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (episodes.size > 1) {
|
||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||
}
|
||||
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,
|
||||
if (state.muted) "Unmute" else "Mute",
|
||||
onClick = toggleMute,
|
||||
)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
CompactIcon(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -996,24 +1210,31 @@ private fun EpisodeChooser(
|
||||
*
|
||||
* "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.
|
||||
*
|
||||
* With [secondsLeft] the card counts down the end of a file that has no credits
|
||||
* chapter, on the file's own clock; the end of file is what advances, and the
|
||||
* second button is "Cancel", since there are no credits to watch.
|
||||
*/
|
||||
@Composable
|
||||
private fun UpNextCard(
|
||||
next: EpisodeRecord?,
|
||||
visible: Boolean,
|
||||
secondsLeft: Int?,
|
||||
onPlayNow: () -> Unit,
|
||||
onStay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var held by remember(next?.linkId) { mutableStateOf(false) }
|
||||
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||
var counted by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||
val remaining = secondsLeft ?: counted
|
||||
val upNext = next
|
||||
val showing = visible && upNext != null && !held
|
||||
LaunchedEffect(showing) {
|
||||
if (!showing) return@LaunchedEffect
|
||||
remaining = UP_NEXT_SECONDS
|
||||
while (remaining > 0) {
|
||||
LaunchedEffect(showing, secondsLeft == null) {
|
||||
if (!showing || secondsLeft != null) return@LaunchedEffect
|
||||
counted = UP_NEXT_SECONDS
|
||||
while (counted > 0) {
|
||||
delay(1_000)
|
||||
remaining -= 1
|
||||
counted -= 1
|
||||
}
|
||||
onPlayNow()
|
||||
}
|
||||
@@ -1076,8 +1297,49 @@ private fun UpNextCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Watch till the end")
|
||||
if (secondsLeft != null) {
|
||||
QuietButton(onClick = { held = true; onStay() }, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Watch till the end")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the player says once an episode ends and nothing starts after it. */
|
||||
@Composable
|
||||
private fun EndCard(
|
||||
finished: String,
|
||||
show: String,
|
||||
next: EpisodeRecord?,
|
||||
onPlayNext: () -> Unit,
|
||||
onReplay: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (next == null) "That was the last episode of $show" else "Finished $finished",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (next != null) {
|
||||
AccentButton(onClick = onPlayNext) { Text("Play next") }
|
||||
} else {
|
||||
QuietButton(onClick = onReplay) { Text("Watch again") }
|
||||
}
|
||||
QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1119,12 +1381,14 @@ private fun CompactIcon(
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
|
||||
// 48dp: the smallest target Android's accessibility guidance allows, and
|
||||
// the size a thumb on a moving train actually hits.
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = description,
|
||||
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.narl.protonstream.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* The settings that are Android's alone.
|
||||
@@ -35,6 +36,26 @@ class SettingsStore(context: Context) {
|
||||
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||
|
||||
/** How the library was last sorted, as a `LibrarySort` name; null for the default. */
|
||||
var librarySort: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_SORT, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
|
||||
|
||||
/** The library's offer of posters and synopses was turned down. */
|
||||
var metadataOfferDismissed: Boolean
|
||||
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
|
||||
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
|
||||
|
||||
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
|
||||
var libraryFilter: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
|
||||
|
||||
/** Whether the library folds each franchise into one tile. */
|
||||
var libraryGrouped: Boolean
|
||||
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
||||
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
||||
|
||||
companion object {
|
||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||
@@ -44,10 +65,19 @@ class SettingsStore(context: Context) {
|
||||
|
||||
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||
|
||||
/** Load the file into memory, so no later read waits on the disk. */
|
||||
fun preload(context: Context) {
|
||||
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
|
||||
}
|
||||
|
||||
private const val NAME = "proton_stream_settings"
|
||||
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||
private const val KEY_LIBRARY_SORT = "library_sort"
|
||||
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
||||
private const val KEY_LIBRARY_FILTER = "library_filter"
|
||||
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,9 @@ internal fun AccountCard(
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Optional. Signed in, watch history is kept in your own Drive so every device " +
|
||||
"resumes where another left off, and folders of your Drive can join the library.",
|
||||
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||
"Drive, so every device shows the same library and resumes where another left off, " +
|
||||
"and folders of your Drive can join the library.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -156,8 +157,8 @@ internal fun AccountCard(
|
||||
|
||||
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||
account.syncError?.let { return "Watch history did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
|
||||
account.syncError?.let { return "Did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||
val ago = when {
|
||||
minutes < 1 -> "just now"
|
||||
|
||||
@@ -25,7 +25,10 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
@@ -38,12 +41,15 @@ import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.SignInOutcome
|
||||
import uniffi.pstr_android.SyncRecord
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
/** A crawl is running, which the banner offers to stop. */
|
||||
val crawling: Boolean = false,
|
||||
val query: String = "",
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
/**
|
||||
@@ -52,6 +58,7 @@ data class AppUiState(
|
||||
* when the grid falls back to [titles] as they are.
|
||||
*/
|
||||
val arrangement: ArrangementRecord? = null,
|
||||
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||
val sort: LibrarySort = LibrarySort.NAME,
|
||||
val grouped: Boolean = true,
|
||||
val shares: List<ShareRecord> = emptyList(),
|
||||
@@ -64,6 +71,12 @@ data class AppUiState(
|
||||
),
|
||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
/**
|
||||
* What is running in the background right now, said in a banner for as
|
||||
* long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||
* minute-long crawl looked like a tap that did nothing.
|
||||
*/
|
||||
val activity: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
val account: AccountUiState = AccountUiState(),
|
||||
@@ -99,6 +112,21 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
private val mutableState = MutableStateFlow(AppUiState())
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||
/** Launch and coming back both ask; one crawl answers. */
|
||||
private var catchingUp = false
|
||||
|
||||
/**
|
||||
* Every banner line whose work is still running, oldest first. Touched on
|
||||
* the main thread only, as everything in [viewModelScope] is.
|
||||
*/
|
||||
private val activities = mutableListOf<Pair<Long, String>>()
|
||||
private var nextActivity = 0L
|
||||
|
||||
/** Crawls running now; main thread only, like [activities]. */
|
||||
private var crawlsRunning = 0
|
||||
|
||||
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
|
||||
private var crawlStopped = false
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
@@ -106,7 +134,22 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
// The arrangement first, so the first grid is drawn the way the viewer
|
||||
// left it rather than A–Z and then re-sorted under them.
|
||||
viewModelScope.launch {
|
||||
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).run {
|
||||
Triple(
|
||||
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
|
||||
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
|
||||
libraryGrouped,
|
||||
)
|
||||
}
|
||||
}
|
||||
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
|
||||
reload()
|
||||
}
|
||||
catchUp()
|
||||
viewModelScope.launch {
|
||||
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||
.getOrDefault(AccountState.SignedOut)
|
||||
@@ -116,6 +159,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
// and no upload.
|
||||
while (isActive) {
|
||||
syncWatchHistory()
|
||||
// A sync WatchSyncWorker ran while the app was away may have
|
||||
// brought shares this one has not crawled.
|
||||
catchUp()
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
@@ -131,10 +177,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder the grid, or fold or unfold its franchises. */
|
||||
/** Sort and fold the library, and keep both for the next launch. */
|
||||
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||
viewModelScope.launch { reloadLibrary(mutableState.value.query) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).apply {
|
||||
librarySort = sort.name
|
||||
libraryGrouped = grouped
|
||||
}
|
||||
}
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow the grid to some titles, and keep the choice for the next launch. */
|
||||
fun filter(filter: LibraryFilter) {
|
||||
mutableState.update { it.copy(filter = filter) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
@@ -144,14 +207,72 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
busy("Refreshing the library…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(null) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||
* a new episode its stored guide cannot name. When everything already has
|
||||
* one the bridge asks nothing, so this is cheap after every crawl.
|
||||
*/
|
||||
private suspend fun matchAfterCrawl() {
|
||||
val settings = mutableState.value.metadataSettings
|
||||
if (settings.enabled && settings.ready) match(force = false, quiet = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Say [what] in the activity banner while [work] runs.
|
||||
*
|
||||
* The banner shows the newest line still running. Work overlaps — a sync
|
||||
* starts during a crawl and ends after it — so each line is removed by
|
||||
* itself rather than by putting back whatever was showing before, which
|
||||
* once brought back a finished crawl's line and spun it forever.
|
||||
*/
|
||||
private suspend fun busy(what: String, work: suspend () -> Unit) {
|
||||
val token = nextActivity++
|
||||
activities += token to what
|
||||
showActivity()
|
||||
try {
|
||||
work()
|
||||
} finally {
|
||||
activities.removeAll { it.first == token }
|
||||
showActivity()
|
||||
}
|
||||
}
|
||||
|
||||
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
|
||||
private suspend fun crawlShares(id: String?) {
|
||||
crawlsRunning++
|
||||
mutableState.update { it.copy(crawling = true) }
|
||||
try {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
|
||||
} finally {
|
||||
crawlsRunning--
|
||||
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the crawls running now. Shares already read stay in the library;
|
||||
* the one being read keeps what it had.
|
||||
*/
|
||||
fun stopCrawl() {
|
||||
crawlStopped = true
|
||||
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
private fun showActivity() {
|
||||
val newest = activities.lastOrNull()?.second
|
||||
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recrawl one share rather than the whole library.
|
||||
*
|
||||
@@ -162,11 +283,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
*/
|
||||
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()
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||
busy("Reading $name…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,26 +303,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* changed.
|
||||
*/
|
||||
fun matchTitles(force: Boolean) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
viewModelScope.launch { match(force) }
|
||||
}
|
||||
|
||||
/**
|
||||
* With [quiet], a run nobody asked for says nothing when it found nothing
|
||||
* to do.
|
||||
*/
|
||||
private suspend fun match(force: Boolean, quiet: Boolean = false) {
|
||||
busy("Looking up titles…") {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
||||
.onSuccess { summary ->
|
||||
if (!quiet || !summary.isEmpty()) {
|
||||
mutableState.update { it.copy(message = describe(summary)) }
|
||||
}
|
||||
}
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
reloadNow()
|
||||
}
|
||||
|
||||
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
|
||||
|
||||
/**
|
||||
* Add a link, and return why it was refused, if it was.
|
||||
*
|
||||
* The link is opened before anything is stored, so the form can stay up
|
||||
* with the reason under it and a retry is a retry — not "already in the
|
||||
* library" for a share that never opened. Only the new share is crawled:
|
||||
* another share that fails to open must not make this one look broken.
|
||||
*/
|
||||
suspend fun addShare(name: String, url: String, password: String?): String? {
|
||||
val share = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
}
|
||||
}.getOrElse { return it.message ?: "That link could not be added" }
|
||||
reload()
|
||||
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl shares that are new here — added on this device or by sync — then
|
||||
* match them when enrichment is on.
|
||||
*/
|
||||
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
|
||||
if (ids.isEmpty()) return
|
||||
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||
val failed = mutableListOf<String>()
|
||||
crawlStopped = false
|
||||
for (id in ids) {
|
||||
if (crawlStopped) break
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||
busy("Reading $name…") {
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { failed += "$name: ${it.message}" }
|
||||
}
|
||||
}
|
||||
reloadNow()
|
||||
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||
// the share's row rather than in their face, and only news is said.
|
||||
if (failed.isNotEmpty() && announce) {
|
||||
mutableState.update { it.copy(message = failed.joinToString("\n")) }
|
||||
return
|
||||
}
|
||||
matchAfterCrawl()
|
||||
if (announce) {
|
||||
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
|
||||
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
|
||||
} else {
|
||||
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
|
||||
if (files > 0uL) {
|
||||
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addShare(name: String, url: String, password: String?) {
|
||||
/**
|
||||
* Crawl shares due a look: ones the catalog has nothing of — brought by a
|
||||
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
|
||||
* whose crawl never finished — and ones not crawled for six hours, so new
|
||||
* episodes turn up without a pull to refresh.
|
||||
*/
|
||||
fun catchUp() {
|
||||
if (catchingUp) return
|
||||
catchingUp = true
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
engine.crawl(null)
|
||||
}
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
reload()
|
||||
try {
|
||||
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||
.getOrDefault(emptyList())
|
||||
crawlAdded(ids, announce = false)
|
||||
} finally {
|
||||
catchingUp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +418,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
engine.crawl(id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a share something else. The library, history and downloads stay. */
|
||||
fun renameShare(id: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
|
||||
.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
@@ -354,7 +562,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
syncError = null,
|
||||
)
|
||||
}
|
||||
if (report.applied > 0u) reload()
|
||||
takeSynced(report)
|
||||
true
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -368,6 +576,33 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What another device changed: its shares crawled or dropped here, its
|
||||
* settings repainted.
|
||||
*/
|
||||
private suspend fun takeSynced(report: SyncRecord) {
|
||||
if (report.sharesRemoved.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val downloads = DownloadStateStore(appContext)
|
||||
report.sharesRemoved.forEach { id ->
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||
downloads.removeShare(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (report.settingsChanged) AppearanceState.reload()
|
||||
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
|
||||
reloadNow()
|
||||
}
|
||||
if (report.sharesAdded.isNotEmpty()) {
|
||||
crawlAdded(report.sharesAdded, announce = false)
|
||||
val count = report.sharesAdded.size
|
||||
mutableState.update {
|
||||
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After the player closes: where it stopped is what another device wants next. */
|
||||
fun playerClosed() {
|
||||
reload()
|
||||
@@ -376,16 +611,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
|
||||
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true) }
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val share = engine.addAccountFolder(name, volumeId, linkId)
|
||||
engine.crawl(share.id)
|
||||
}
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||
}.onSuccess { share ->
|
||||
reload()
|
||||
crawlAdded(listOf(share.id), announce = true)
|
||||
}.onFailure(::reportError)
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,11 +625,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||
val episodes = title.seasons.flatMap { it.episodes }
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||
|
||||
/**
|
||||
* Some episodes seen or unseen — a season, or a whole title — with an
|
||||
* Undo; [what] names them in the message.
|
||||
*/
|
||||
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
|
||||
changeWatch(
|
||||
episodes,
|
||||
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
|
||||
episodes.filter { it.watched != watched },
|
||||
"$what marked ${if (watched) "watched" else "unwatched"}",
|
||||
) { engine, episode, before ->
|
||||
val duration = before?.durationSecs
|
||||
engine.saveWatchState(
|
||||
@@ -446,14 +683,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
snapshots.forEach { snapshot ->
|
||||
val state = snapshot.state
|
||||
engine.saveWatchState(
|
||||
snapshot.shareId,
|
||||
snapshot.linkId,
|
||||
state?.positionSecs ?: 0.0,
|
||||
state?.durationSecs,
|
||||
state?.watched ?: false,
|
||||
)
|
||||
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||
}
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
@@ -487,22 +717,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||
}
|
||||
|
||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
if (enabled) engine.matchTitles(false) else null
|
||||
/**
|
||||
* Store the enrichment settings, and return why not, if they were not.
|
||||
*
|
||||
* The page shows the new settings as soon as they are stored; the match
|
||||
* run that follows can take minutes and says so in the activity banner.
|
||||
*/
|
||||
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||
val stored = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
}
|
||||
}.onSuccess { summary ->
|
||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
engine.metadataSettings()
|
||||
}
|
||||
}.getOrElse { return it.message ?: "The settings could not be saved" }
|
||||
mutableState.update { it.copy(metadataSettings = stored) }
|
||||
reloadNow()
|
||||
if (enabled) viewModelScope.launch { match(force = false) }
|
||||
return null
|
||||
}
|
||||
|
||||
fun reloadAfterMetadataChange() = reload()
|
||||
@@ -585,24 +820,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
/** One episode's tick, with the same Undo a season's has. */
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||
|
||||
fun removeAllOffline() {
|
||||
viewModelScope.launch {
|
||||
@@ -629,40 +848,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
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()
|
||||
val current = mutableState.value
|
||||
val query = current.query.takeIf(String::isNotBlank)
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(query),
|
||||
engine.arrangement(query, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
arrangement = result.arrangement,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch { reloadNow() }
|
||||
}
|
||||
|
||||
/** [reload], for a caller that goes on to read what it loaded. */
|
||||
private suspend fun reloadNow() {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
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()
|
||||
val current = mutableState.value
|
||||
val query = current.query.takeIf(String::isNotBlank)
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(query),
|
||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
arrangement = result.arrangement,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadLibrary(query: String) {
|
||||
@@ -671,7 +893,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val search = query.takeIf(String::isNotBlank)
|
||||
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
|
||||
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
|
||||
}
|
||||
}.onSuccess { (titles, arrangement) ->
|
||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -15,11 +18,22 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
@@ -93,21 +107,33 @@ internal fun Modifier.sharedArt(key: String): Modifier {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EmptyState(title: String, body: String) {
|
||||
internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Play on a title lands: the most recently played part-watched episode,
|
||||
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||
* Where Play on a title lands: what Continue watching offers
|
||||
* ([TitleRecord.continueIndex]), else the most recently played part-watched
|
||||
* episode, else the first unwatched one, else the first — the desktop's
|
||||
* `next_up`.
|
||||
*/
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||
continueIndex?.toInt()?.takeIf { it in playlist.indices }
|
||||
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
@@ -122,11 +148,52 @@ internal fun formatBytes(bytes: ULong): String {
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||
* Download episodes, asking first when it is more than one: a season or a
|
||||
* show is tens of gigabytes, and one tap should not fill a phone. The
|
||||
* question says how many and how big. Call it in composition; it draws the
|
||||
* dialog where it is called and returns what starts the download.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
@Composable
|
||||
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
|
||||
asking?.let { (episodes, what) ->
|
||||
val bytes = episodes.sumOf { it.size ?: 0uL }
|
||||
AlertDialog(
|
||||
onDismissRequest = { asking = null },
|
||||
title = { Text("Download ${episodes.size} episodes of $what?") },
|
||||
text = {
|
||||
Text(
|
||||
"They are kept on this device for watching without a connection" +
|
||||
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
|
||||
". Delete them from Downloads.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
asking = null
|
||||
DownloadCoordinator.enqueue(context, episodes)
|
||||
}) { Text("Download") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
return remember(context) {
|
||||
{ episodes, what ->
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline)
|
||||
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
|
||||
* [waitingFor], what it is really waiting on.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
|
||||
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
|
||||
?: download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
@@ -135,6 +202,25 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
|
||||
?.let { "${formatBytes(it.toULong())}/s" },
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* Why a queued download is not running, when it is not merely a free slot:
|
||||
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
|
||||
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
|
||||
* changes — the download's own updates are the clock.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberQueueReason(key: Any?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(key) {
|
||||
val network = context.getSystemService(ConnectivityManager::class.java)
|
||||
when {
|
||||
network?.activeNetwork == null -> "Waiting for a connection"
|
||||
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an episode row is headed with: "1. Mother and Children".
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.narl.protonstream.ui
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -40,7 +42,7 @@ import androidx.work.WorkManager
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onDeletePartial: (RetainedDownload) -> Unit,
|
||||
padding: PaddingValues,
|
||||
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||
onOpen: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
|
||||
)
|
||||
}
|
||||
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||
SavedRow(file, group.title, onDelete = { deleting = file })
|
||||
SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +142,7 @@ internal fun DownloadsScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
DangerButton(onClick = {
|
||||
deleting = null
|
||||
onRemove(file)
|
||||
}) { Text("Delete") }
|
||||
@@ -160,9 +164,23 @@ private fun SectionHeading(title: String, caption: String? = null) {
|
||||
|
||||
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||
@Composable
|
||||
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
|
||||
private fun SavedRow(
|
||||
file: OfflineRecord,
|
||||
title: TitleRecord?,
|
||||
onDelete: () -> Unit,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
) {
|
||||
val episode = file.episode
|
||||
// What a download is for: tapping it plays it, from where it was left,
|
||||
// without going back through the library to find it.
|
||||
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
|
||||
ListItem(
|
||||
modifier = if (title != null && index >= 0) {
|
||||
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
leadingContent = {
|
||||
RemoteArtwork(
|
||||
episode?.stillUrl ?: title?.backdropUrl,
|
||||
@@ -179,8 +197,15 @@ private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> U
|
||||
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
Row {
|
||||
title?.let {
|
||||
IconButton(onClick = { onOpen(it) }) {
|
||||
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
@@ -204,7 +229,7 @@ private fun PartialRow(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(downloadStatusLine(download))
|
||||
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
|
||||
@@ -19,6 +19,10 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import java.time.LocalDate
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
@@ -35,8 +39,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -61,6 +63,7 @@ import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
@@ -70,6 +73,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -82,6 +88,7 @@ import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
@@ -96,11 +103,50 @@ import uniffi.pstr_android.TitleType
|
||||
private const val CONTINUE_WATCHING_MAX = 12
|
||||
|
||||
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||
private data class Resumable(
|
||||
val title: TitleRecord,
|
||||
val episode: EpisodeRecord,
|
||||
val index: Int,
|
||||
/** When the title was last played, which orders the shelf. */
|
||||
val lastPlayed: Long,
|
||||
)
|
||||
|
||||
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
|
||||
private data class Tile(val title: TitleRecord, val folded: Int)
|
||||
|
||||
/** The desktop's names for the filters, from `browse::Filter::label`. */
|
||||
internal fun LibraryFilter.label(): String = when (this) {
|
||||
LibraryFilter.ALL -> "All"
|
||||
LibraryFilter.SERIES -> "Series"
|
||||
LibraryFilter.FILMS -> "Films"
|
||||
LibraryFilter.WATCHING -> "Watching"
|
||||
LibraryFilter.UNWATCHED -> "Not started"
|
||||
LibraryFilter.DOWNLOADED -> "Downloaded"
|
||||
}
|
||||
|
||||
/** The offer of posters and synopses, and its two answers. */
|
||||
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun MetadataOfferCard(offer: MetadataOffer) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Text("Add posters and synopses?", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Turning it on in Settings sends the titles in your library — not your files, and " +
|
||||
"nothing about what you have watched — to AniList or TMDB.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, end = 8.dp),
|
||||
)
|
||||
Row(Modifier.align(Alignment.End)) {
|
||||
QuietButton(onClick = offer.onDismiss) { Text("Not now") }
|
||||
QuietButton(onClick = offer.onOpenSettings) { Text("Open settings") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row above the grid: titles that share a director, a studio or a genre. */
|
||||
private data class Shelf(val label: String, val titles: List<TitleRecord>)
|
||||
|
||||
@@ -118,22 +164,31 @@ internal fun LibraryScreen(
|
||||
onMatchChanged: () -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||
onAddShare: () -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
) {
|
||||
val download = rememberDownload()
|
||||
val context = LocalContext.current
|
||||
// Posters and synopses, offered once there is a library to put them on
|
||||
// and never turned on unasked: switching them on sends title names out.
|
||||
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
|
||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
// Most recently played first, one episode per show: a shelf that lists four
|
||||
// episodes of the same series is a shelf with room for nothing else.
|
||||
// What to go on with is the engine's call (`continueIndex`): the
|
||||
// part-watched episode, or the one after the episode finished last.
|
||||
val resumable = remember(state.titles) {
|
||||
state.titles.mapNotNull { title ->
|
||||
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
|
||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
playlist.withIndex()
|
||||
.filter { it.value.resumeAt != null && !it.value.watched }
|
||||
.maxByOrNull { it.value.lastPlayed }
|
||||
?.let { Resumable(title, it.value, it.index) }
|
||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||
playlist.getOrNull(index)?.let { episode ->
|
||||
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
|
||||
}
|
||||
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||
}
|
||||
// Search is an icon until it is wanted: a permanent field above the
|
||||
// library spent a band of every visit on something used in few of them.
|
||||
@@ -179,9 +234,16 @@ internal fun LibraryScreen(
|
||||
"Nothing matches",
|
||||
"No title in the library is called “${state.query.trim()}”.",
|
||||
)
|
||||
// A share just added: say it is being looked through, rather
|
||||
// than offer to add the one that is already filling the page.
|
||||
state.titles.isEmpty() && state.activity != null -> EmptyState(
|
||||
"Looking through the share",
|
||||
"${state.activity} Its titles appear here once it has been listed.",
|
||||
)
|
||||
state.titles.isEmpty() -> EmptyState(
|
||||
"Your library is empty",
|
||||
"Add a Proton Drive public link under Shares, then refresh.",
|
||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||
action = "Add a share" to onAddShare,
|
||||
)
|
||||
else -> LibraryGrid(
|
||||
state,
|
||||
@@ -190,6 +252,15 @@ internal fun LibraryScreen(
|
||||
onTitle,
|
||||
onTitleMenu = { menuFor = it },
|
||||
onResumeMenu = { menuResume = it },
|
||||
onFilter = onFilter,
|
||||
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
|
||||
null
|
||||
} else {
|
||||
MetadataOffer(onOpenSettings) {
|
||||
offerDismissed = true
|
||||
SettingsStore(context).metadataOfferDismissed = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,10 +270,10 @@ internal fun LibraryScreen(
|
||||
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||
onResume(title, nextUpIndex(playlist))
|
||||
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
||||
}
|
||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
||||
MenuRow(
|
||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||
@@ -384,6 +455,8 @@ private fun LibraryGrid(
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onTitleMenu: (TitleRecord) -> Unit,
|
||||
onResumeMenu: (Resumable) -> Unit,
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
metadataOffer: MetadataOffer? = null,
|
||||
) {
|
||||
// The arrangement names titles by key; before it has arrived, the titles
|
||||
// as they came are the grid.
|
||||
@@ -410,6 +483,9 @@ private fun LibraryGrid(
|
||||
// opening the app wants is almost always the thing they were part way
|
||||
// through. Inside the grid rather than pinned over it, so the shelf
|
||||
// scrolls away with the rest.
|
||||
if (metadataOffer != null && state.query.isBlank()) {
|
||||
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
|
||||
}
|
||||
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||
featured?.let { title ->
|
||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||
@@ -441,7 +517,10 @@ private fun LibraryGrid(
|
||||
}
|
||||
}
|
||||
if (state.query.isBlank()) {
|
||||
shelves.forEach { shelf ->
|
||||
// Only over the whole library: under a filter, a shelf of titles
|
||||
// the grid below is hiding reads as the filter not working. The
|
||||
// bridge leaves them out too; this covers the frame before it.
|
||||
shelves.takeIf { state.filter == LibraryFilter.ALL }.orEmpty().forEach { shelf ->
|
||||
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
|
||||
@@ -462,9 +541,29 @@ private fun LibraryGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(LibraryFilter.entries, key = { it.name }) { filter ->
|
||||
FilterChip(
|
||||
selected = filter == state.filter,
|
||||
onClick = { onFilter(filter) },
|
||||
label = { Text(filter.label()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tiles.isEmpty() && state.filter != LibraryFilter.ALL) {
|
||||
item(key = "filtered-out", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
"Nothing in the library fits that filter.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,7 +666,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
|
||||
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (resume != null) "Resume" else "Play")
|
||||
@@ -585,14 +684,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
||||
Modifier
|
||||
.width(220.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this episode"),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||
// Named by the text under it; read as well, the name came twice.
|
||||
RemoteArtwork(
|
||||
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||
entry.title.displayName,
|
||||
Modifier.fillMaxSize(),
|
||||
Modifier.fillMaxSize().clearAndSetSemantics {},
|
||||
fallback = entry.episode.thumbnailSource,
|
||||
maxEdge = 220.dp,
|
||||
)
|
||||
entry.episode.progress?.let { progress ->
|
||||
AccentProgress(
|
||||
@@ -609,7 +710,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||
if (entry.episode.resumeAt == null) {
|
||||
"Next: ${entry.episode.label}"
|
||||
} else {
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
@@ -638,7 +743,7 @@ private fun PosterTile(
|
||||
Column(
|
||||
modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
|
||||
) {
|
||||
Box {
|
||||
RemoteArtwork(
|
||||
@@ -647,8 +752,11 @@ private fun PosterTile(
|
||||
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clearAndSetSemantics {},
|
||||
fallback = title.thumbnailSource,
|
||||
// The tile's height, at the widest a grid column gets.
|
||||
maxEdge = 280.dp,
|
||||
)
|
||||
if (folded > 0) {
|
||||
Text(
|
||||
@@ -660,7 +768,8 @@ private fun PosterTile(
|
||||
.padding(6.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "and $folded more" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
@@ -65,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.work.WorkManager
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
@@ -98,7 +110,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
|
||||
@Composable
|
||||
fun ProtonStreamApp(
|
||||
playerHost: LibmpvHost? = null,
|
||||
inPictureInPicture: Boolean = false,
|
||||
shareLink: String? = null,
|
||||
onShareLinkTaken: () -> Unit = {},
|
||||
model: AppViewModel = viewModel(
|
||||
@@ -123,6 +134,7 @@ fun ProtonStreamApp(
|
||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||
// into the video they had just left.
|
||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||
var addingShare by remember { mutableStateOf(false) }
|
||||
|
||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||
val playing = playingTitleKey
|
||||
@@ -216,6 +228,12 @@ fun ProtonStreamApp(
|
||||
onMatchChanged = model::reloadAfterMetadataChange,
|
||||
onError = model::reportError,
|
||||
onArrange = model::arrange,
|
||||
onAddShare = {
|
||||
destination = Destination.Shares
|
||||
addingShare = true
|
||||
},
|
||||
onOpenSettings = { destination = Destination.Settings },
|
||||
onFilter = model::filter,
|
||||
)
|
||||
}
|
||||
val titlePane = @Composable { selected: TitleRecord ->
|
||||
@@ -232,6 +250,7 @@ fun ProtonStreamApp(
|
||||
model::reloadAfterMetadataChange,
|
||||
model::setWatched,
|
||||
body,
|
||||
onMarkEpisodes = model::markEpisodes,
|
||||
franchise = selected.franchise.mapNotNull { key ->
|
||||
state.titles.firstOrNull { it.key == key }
|
||||
},
|
||||
@@ -263,12 +282,21 @@ fun ProtonStreamApp(
|
||||
}
|
||||
Destination.Shares -> SharesScreen(
|
||||
state.shares,
|
||||
model::addShare,
|
||||
{ name, url, password ->
|
||||
// The first share is the library: watch it fill
|
||||
// rather than stay on a form that has done its job.
|
||||
val first = state.titles.isEmpty()
|
||||
model.addShare(name, url, password).also { refusal ->
|
||||
if (refusal == null && first) destination = Destination.Library
|
||||
}
|
||||
},
|
||||
model::repairShare,
|
||||
model::refreshShare,
|
||||
model::removeShare,
|
||||
shareLink,
|
||||
onShareLinkTaken,
|
||||
// The empty library's button opens the form as a
|
||||
// pasted link would, only with nothing in it.
|
||||
shareLink ?: "".takeIf { addingShare },
|
||||
{ addingShare = false; onShareLinkTaken() },
|
||||
body,
|
||||
account = state.account,
|
||||
accountActions = AccountActions(
|
||||
@@ -282,6 +310,7 @@ fun ProtonStreamApp(
|
||||
addFolder = model::addAccountFolder,
|
||||
onError = model::reportError,
|
||||
),
|
||||
onRename = model::renameShare,
|
||||
)
|
||||
Destination.History -> HistoryScreen(
|
||||
state,
|
||||
@@ -301,6 +330,15 @@ fun ProtonStreamApp(
|
||||
model::resumeDownload,
|
||||
model::deletePartial,
|
||||
body,
|
||||
onPlay = { title, index ->
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
onOpen = { title ->
|
||||
destination = Destination.Library
|
||||
selectedTitleKey = title.key
|
||||
},
|
||||
)
|
||||
Destination.Settings -> SettingsScreen(
|
||||
state,
|
||||
@@ -327,22 +365,28 @@ fun ProtonStreamApp(
|
||||
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
}
|
||||
ActivityBanner(
|
||||
state.activity,
|
||||
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn over the scaffold rather than instead of it. The player owns the
|
||||
// whole window while it is up — it is a screen, not a dialog over one, which
|
||||
// is what lets it draw into the cutout and hand the right window to
|
||||
// Picture-in-Picture — but the page behind it stays composed, so leaving the
|
||||
// player finds the title page where it was rather than rebuilt from the top.
|
||||
// is what lets it draw into the cutout — but the page behind it stays
|
||||
// composed, so leaving the player finds the title page where it was rather
|
||||
// than rebuilt from the top.
|
||||
playing?.let { request ->
|
||||
PlayerScreen(
|
||||
title = request.title,
|
||||
episodes = request.episodes,
|
||||
index = request.index,
|
||||
host = playerHost,
|
||||
inPictureInPicture = inPictureInPicture,
|
||||
minimized = playerMinimized,
|
||||
onIndexChange = { playingIndex = it },
|
||||
onSaveProgress = model::saveProgress,
|
||||
@@ -400,3 +444,37 @@ private fun MiniTransport(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the app is busy with, for as long as it is: a spinner and one line.
|
||||
* Not dismissible and not timed — it goes when the work does.
|
||||
*/
|
||||
@Composable
|
||||
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = activity != null,
|
||||
modifier = modifier,
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
) {
|
||||
// Held through the exit, so the line does not blank before it fades.
|
||||
var shown by remember { mutableStateOf(activity.orEmpty()) }
|
||||
if (activity != null) shown = activity
|
||||
Row(
|
||||
Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
|
||||
// A crawl of a large share can take minutes, and is the viewer's
|
||||
// to call off; what it has read so far stays.
|
||||
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -87,16 +88,20 @@ internal fun RemoteArtwork(
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: ThumbnailSource? = null,
|
||||
labelled: Boolean = true,
|
||||
maxEdge: Dp? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||
// and decoding its poster at 1024 holds four times the memory for nothing.
|
||||
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
||||
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||
LaunchedEffect(url, fallback, enabled) {
|
||||
if (!enabled) {
|
||||
image = ArtworkLoad.Missing
|
||||
return@LaunchedEffect
|
||||
}
|
||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
|
||||
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||
?: ArtworkLoad.Missing
|
||||
}
|
||||
@@ -186,8 +191,8 @@ internal fun initials(name: String): String {
|
||||
* 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) {
|
||||
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val uri = URI(requested)
|
||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||
@@ -197,7 +202,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
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" }
|
||||
val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "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
|
||||
@@ -210,7 +215,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
}
|
||||
bitmap
|
||||
}.getOrNull()?.also { decoded.put(requested, it) }
|
||||
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,21 +229,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
* 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? {
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
|
||||
val file = source ?: return null
|
||||
val key = file.key
|
||||
decoded.get(key)?.let { return it }
|
||||
decoded.get(sized(key, edge))?.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) } }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
|
||||
if (bitmap == null) {
|
||||
missing.add(key)
|
||||
return null
|
||||
}
|
||||
decoded.put(key, bitmap)
|
||||
decoded.put(sized(key, edge), bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@@ -257,28 +262,43 @@ private fun download(uri: URI): ByteArray {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode within a bound the display can actually use.
|
||||
* Decode with its longest edge at most [edge] pixels.
|
||||
*
|
||||
* [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.
|
||||
*
|
||||
* Sampling halves, so it stops while the image is still at least [edge]; the
|
||||
* decoder scales the rest of the way, which a poster sampled straight to under
|
||||
* its tile would show as blur.
|
||||
*/
|
||||
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
||||
private fun decodeSampled(bytes: ByteArray, edge: Int): 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
|
||||
while (longest / (sample * 2) >= edge) sample *= 2
|
||||
val sampled = longest / sample
|
||||
return BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
if (sampled > edge) {
|
||||
inScaled = true
|
||||
inDensity = sampled
|
||||
inTargetDensity = edge
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The decoded cache's key for one image at one size. */
|
||||
private fun sized(key: String, edge: Int) = "$key#$edge"
|
||||
|
||||
/**
|
||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||
*
|
||||
@@ -315,7 +335,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
|
||||
* 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(),
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
|
||||
) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
@@ -86,7 +87,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
|
||||
@Composable
|
||||
internal fun SettingsScreen(
|
||||
state: AppUiState,
|
||||
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||
/** Resolves to why the settings were not stored, or null once they are. */
|
||||
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
onMatchAgain: () -> Unit,
|
||||
onClearCache: () -> Unit,
|
||||
onRemoveAllOffline: () -> Unit,
|
||||
@@ -315,7 +317,7 @@ internal fun SettingsScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||
DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||
)
|
||||
@@ -327,10 +329,7 @@ internal fun SettingsScreen(
|
||||
MetadataSettingsDialog(
|
||||
current = state.metadataSettings,
|
||||
onDismiss = { showMetadata = false },
|
||||
onSave = { enabled, provider, language, key ->
|
||||
onSaveMetadata(enabled, provider, language, key)
|
||||
showMetadata = false
|
||||
},
|
||||
onSave = onSaveMetadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -411,14 +410,19 @@ private fun SettingAction(
|
||||
private fun MetadataSettingsDialog(
|
||||
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
) {
|
||||
var enabled by remember { mutableStateOf(current.enabled) }
|
||||
var provider by remember { mutableStateOf(current.provider) }
|
||||
var language by remember { mutableStateOf(current.language) }
|
||||
var apiKey by remember { mutableStateOf("") }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
|
||||
val forgets = current.enabled && (!enabled || provider != current.provider)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Metadata enrichment") },
|
||||
text = {
|
||||
@@ -447,15 +451,35 @@ private fun MetadataSettingsDialog(
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||
// Said before Save rather than after: what is forgotten
|
||||
// includes matches chosen by hand, which no look-up redoes.
|
||||
if (forgets) {
|
||||
Text(
|
||||
"Saving forgets every poster, description and match stored now, " +
|
||||
"including ones you chose by hand.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
||||
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
||||
) { Text("Save") }
|
||||
enabled = !needsKey && !saving,
|
||||
onClick = {
|
||||
saving = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
|
||||
saving = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -25,13 +30,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -45,12 +54,15 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FolderShared
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
internal fun SharesScreen(
|
||||
shares: List<ShareRecord>,
|
||||
onAdd: (String, String, String?) -> Unit,
|
||||
/** Resolves to why the link was refused, or null once it is added. */
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
onRepair: (String, String, String?) -> Unit,
|
||||
onRefresh: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
@@ -59,8 +71,10 @@ internal fun SharesScreen(
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
onRename: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
var browsing by remember { mutableStateOf(false) }
|
||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
@@ -97,6 +111,7 @@ internal fun SharesScreen(
|
||||
onRefresh = { onRefresh(share.id) },
|
||||
onRepair = { repairing = share },
|
||||
onRemove = { removing = share },
|
||||
onRename = { renaming = share },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +150,21 @@ internal fun SharesScreen(
|
||||
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||
)
|
||||
}
|
||||
renaming?.let { share ->
|
||||
var name by remember(share.id) { mutableStateOf(share.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Rename ${share.name}") },
|
||||
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
|
||||
confirmButton = {
|
||||
AccentButton(enabled = name.isNotBlank(), onClick = {
|
||||
renaming = null
|
||||
onRename(share.id, name.trim())
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
removing?.let { share ->
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
@@ -148,7 +178,7 @@ internal fun SharesScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||
DangerButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||
)
|
||||
@@ -161,24 +191,58 @@ internal fun SharesScreen(
|
||||
* Three differently styled controls beside the name squeezed it into a column
|
||||
* a word wide; one icon leaves the row its width.
|
||||
*/
|
||||
/** "120 files · crawled 2 h ago", or that it has not been yet. */
|
||||
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
|
||||
val at = share.crawledAt ?: return "Not crawled yet"
|
||||
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
|
||||
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
|
||||
}
|
||||
|
||||
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
|
||||
internal fun ago(at: Long, now: Long): String {
|
||||
val seconds = (now - at).coerceAtLeast(0)
|
||||
return when {
|
||||
seconds < 60 -> "just now"
|
||||
seconds < 3_600 -> "${seconds / 60} min ago"
|
||||
seconds < 86_400 -> "${seconds / 3_600} h ago"
|
||||
seconds < 172_800 -> "1 day ago"
|
||||
else -> "${seconds / 86_400} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareRow(
|
||||
share: ShareRecord,
|
||||
onRefresh: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onRename: () -> Unit = {},
|
||||
) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password, stored securely"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
Text(crawlLine(share), style = MaterialTheme.typography.bodySmall)
|
||||
// Until a crawl succeeds: a link revoked upstream otherwise
|
||||
// just stops growing.
|
||||
share.crawlError?.let {
|
||||
Text(
|
||||
"Last crawl failed: $it",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
@@ -200,6 +264,11 @@ private fun ShareRow(
|
||||
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
onClick = { menu = false; onRefresh() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||
onClick = { menu = false; onRename() },
|
||||
)
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
@@ -277,32 +346,55 @@ private fun RepairShareDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a link. The form stays up while the link is opened, and with the reason
|
||||
* under it when the link is refused, so fixing a typo is a retry rather than
|
||||
* starting over.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddShareDialog(
|
||||
initialUrl: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String, String, String?) -> Unit,
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||
var showLink by remember { mutableStateOf(false) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { if (!adding) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Add Proton Drive share") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
url,
|
||||
{ url = it },
|
||||
label = { Text("Public share URL") },
|
||||
label = { Text("Share link") },
|
||||
singleLine = true,
|
||||
// The URL fragment is a secret. Password input disables
|
||||
// IME learning/suggestions while normal long-press paste
|
||||
// remains available.
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
// remains available; the eye shows what was pasted.
|
||||
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showLink = !showLink }) {
|
||||
Icon(
|
||||
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showLink) "Hide link" else "Show link",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{ name = it },
|
||||
label = { Text("Name (optional)") },
|
||||
supportingText = { Text("Defaults to the shared folder's name") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
password,
|
||||
@@ -312,14 +404,30 @@ private fun AddShareDialog(
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
if (adding) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text("Opening the link…")
|
||||
}
|
||||
} else {
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
||||
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||
) { Text("Add") }
|
||||
onClick = {
|
||||
adding = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onAdd(name.trim(), url.trim(), password)
|
||||
adding = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = url.isNotBlank() && !adding,
|
||||
) { Text(if (adding) "Adding…" else "Add") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -30,6 +31,8 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,14 +41,17 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.RemoveDone
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -68,6 +74,7 @@ import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
@@ -97,11 +104,14 @@ internal fun TitleScreen(
|
||||
onMetadataChanged: () -> Unit,
|
||||
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||
padding: PaddingValues,
|
||||
/** Several episodes seen or unseen at once, named for the message. */
|
||||
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
|
||||
/** Every title of its franchise, in release order, this one included. */
|
||||
franchise: List<TitleRecord> = emptyList(),
|
||||
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val download = rememberDownload()
|
||||
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||
|
||||
@@ -122,7 +132,7 @@ internal fun TitleScreen(
|
||||
// Display order across seasons: what previous/next and autoplay walk, and
|
||||
// the same order the desktop client uses.
|
||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
|
||||
val upNext = playlist.getOrNull(nextUp)
|
||||
// One season on screen at a time, opened on the one Play would land in: a
|
||||
// forty-episode show as stacked sections is one long scroll to the part
|
||||
@@ -138,7 +148,11 @@ internal fun TitleScreen(
|
||||
// without a trip to the Downloads tab.
|
||||
val work by WorkManager.getInstance(context)
|
||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||
// Decoded off the main thread: WorkManager reports progress several times
|
||||
// a second while a season downloads, and each report re-reads every record.
|
||||
val downloads by produceState(emptyList<RetainedDownload>(), work) {
|
||||
value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() }
|
||||
}
|
||||
|
||||
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||
// button and synopsis lines two thousand pixels wide are read by turning
|
||||
@@ -266,7 +280,7 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||
DownloadCoordinator.enqueue(context, playlist)
|
||||
download(playlist, title.displayName)
|
||||
}
|
||||
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes
|
||||
@@ -343,7 +357,9 @@ internal fun TitleScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||
// By position: two seasons can share a label, and a
|
||||
// repeated key is a crash in a lazy list.
|
||||
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
@@ -352,7 +368,16 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
season?.let {
|
||||
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||
// For someone joining the show partway, who would
|
||||
// otherwise tick every earlier episode one by one.
|
||||
val seen = it.episodes.all(EpisodeRecord::watched)
|
||||
IconButton(onClick = { onMarkEpisodes(it.episodes, !seen, "${title.displayName} ${it.label}") }) {
|
||||
Icon(
|
||||
if (seen) Icons.Default.RemoveDone else Icons.Default.DoneAll,
|
||||
contentDescription = "Mark ${it.label} ${if (seen) "unwatched" else "watched"}",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { download(it.episodes, "${title.displayName} ${it.label}") }) {
|
||||
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||
}
|
||||
}
|
||||
@@ -374,6 +399,14 @@ internal fun TitleScreen(
|
||||
onPause = { DownloadCoordinator.pause(context, it) },
|
||||
onResume = { DownloadCoordinator.resume(context, it) },
|
||||
onSetWatched = { onSetWatched(episode, it) },
|
||||
onWatchedUpTo = {
|
||||
val upTo = playlist.indexOfFirst { it.linkId == episode.linkId }
|
||||
onMarkEpisodes(
|
||||
playlist.take(upTo + 1),
|
||||
true,
|
||||
"${title.displayName} up to ${episode.label}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -521,16 +554,35 @@ private fun EpisodeRow(
|
||||
onPause: (RetainedDownload) -> Unit,
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onSetWatched: (Boolean) -> Unit,
|
||||
onWatchedUpTo: () -> Unit = {},
|
||||
) {
|
||||
// A long press, for someone joining a show partway: everything before
|
||||
// this one ticked at once rather than one by one.
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||
download?.status == RetainedDownload.STATUS_QUEUED
|
||||
val heading = episode.heading(position)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = playerReady, onClick = onPlay)
|
||||
.combinedClickable(
|
||||
enabled = playerReady,
|
||||
onClick = onPlay,
|
||||
onLongClick = { menu = true },
|
||||
onLongClickLabel = "More for this episode",
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Mark watched up to here") },
|
||||
leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) },
|
||||
onClick = {
|
||||
menu = false
|
||||
onWatchedUpTo()
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// With metadata off the provider has no still, and Proton's own
|
||||
// thumbnail is a frame of this very episode. Where it was left sits
|
||||
@@ -636,7 +688,7 @@ private fun EpisodeRow(
|
||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||
)
|
||||
Text(
|
||||
downloadStatusLine(active),
|
||||
downloadStatusLine(active, rememberQueueReason(active)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -656,7 +708,33 @@ internal fun ChangeMatchDialog(
|
||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||
var confirmForget by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
if (confirmForget) {
|
||||
// Asked, because a match chosen by hand is work the next look-up
|
||||
// does not redo: it decides again on its own.
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmForget = false },
|
||||
title = { Text("Forget this match?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its poster, description and episode names go, and the next look-up " +
|
||||
"matches it again on its own. A match you chose by hand is lost.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(onClick = {
|
||||
confirmForget = false
|
||||
scope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||
.onSuccess { onChanged() }.onFailure(onError)
|
||||
}
|
||||
}) { Text("Forget") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } },
|
||||
)
|
||||
return
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Change match") },
|
||||
@@ -705,12 +783,7 @@ internal fun ChangeMatchDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
QuietButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||
.onSuccess { onChanged() }.onFailure(onError)
|
||||
}
|
||||
}) { Text("Forget match") }
|
||||
QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
|
||||
@@ -142,6 +142,28 @@ internal fun QuietButton(
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* The yes of a confirmation that deletes or forgets something.
|
||||
*
|
||||
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
|
||||
* whose most inviting button is the one that loses something reads as the
|
||||
* safe choice. Error-toned text instead, as Material's own guidance has it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DangerButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far along something is, drawn in the accent.
|
||||
*
|
||||
|
||||
@@ -56,6 +56,12 @@ object AppearanceState {
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Read the stored choice again, after sync rewrote it. */
|
||||
suspend fun reload() {
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ class AccountUiTest {
|
||||
@Test
|
||||
fun `the sync line says how long ago and what came in`() {
|
||||
val minute = 60_000L
|
||||
assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||
assertEquals(
|
||||
@@ -21,7 +21,7 @@ class AccountUiTest {
|
||||
@Test
|
||||
fun `a failed sync is said in place of when it last worked`() {
|
||||
assertEquals(
|
||||
"Watch history did not sync: offline",
|
||||
"Did not sync: offline",
|
||||
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class HistoryTest {
|
||||
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
continueIndex = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
@@ -79,18 +81,23 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the library on a phone`() = capture("library-phone") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the library on a tablet`() = capture("library-tablet") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,7 +123,7 @@ class ScreenshotTest {
|
||||
// reads it from, so the scale is set there.
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,22 +144,22 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the shares page`() = capture("shares-phone") {
|
||||
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the settings page`() = capture("settings-phone") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the playback settings`() = capture("settings-playback") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the storage settings`() = capture("settings-storage") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -264,6 +271,9 @@ class ScreenshotTest {
|
||||
nextEpisode = null,
|
||||
nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
// What the engine would pick for these fixtures: the part-watched episode.
|
||||
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
|
||||
.takeIf { it >= 0 }?.toUInt(),
|
||||
)
|
||||
|
||||
val SERIES = title(
|
||||
@@ -290,6 +300,16 @@ class ScreenshotTest {
|
||||
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||
.copy(kind = TitleType.FILM, year = 1988u)
|
||||
|
||||
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
|
||||
val MATCHED get() = LIBRARY.copy(
|
||||
metadataSettings = MetadataSettingsRecord(
|
||||
enabled = true,
|
||||
provider = MetadataProvider.ANI_LIST,
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
)
|
||||
|
||||
val LIBRARY = AppUiState(
|
||||
loading = false,
|
||||
titles = listOf(
|
||||
@@ -306,9 +326,17 @@ class ScreenshotTest {
|
||||
},
|
||||
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
||||
),
|
||||
// Crawled relative to the clock, so the row reads "2 h ago" on
|
||||
// whatever day the test runs.
|
||||
shares = listOf(
|
||||
ShareRecord("anime", "anime", true),
|
||||
ShareRecord("films", "films", false),
|
||||
ShareRecord(
|
||||
"anime",
|
||||
"anime",
|
||||
true,
|
||||
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
|
||||
crawlFiles = 120uL,
|
||||
),
|
||||
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
|
||||
),
|
||||
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 631 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 670 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -4,7 +4,7 @@
|
||||
//! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing;
|
||||
//! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -12,7 +12,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||
use pstr_core::browse::Sort;
|
||||
use pstr_core::browse::{Filter, Sort};
|
||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||
use pstr_core::chapters::{Chapter, ChapterRole};
|
||||
use pstr_core::config::AppDirs;
|
||||
@@ -140,6 +140,18 @@ pub struct ShareRecord {
|
||||
/// A folder of the signed-in account rather than a public link.
|
||||
#[uniffi(default = false)]
|
||||
pub from_account: bool,
|
||||
/// When the last crawl here finished, Unix seconds. `None` before one has.
|
||||
#[uniffi(default = None)]
|
||||
pub crawled_at: Option<i64>,
|
||||
/// Playable files that crawl stored.
|
||||
#[uniffi(default = None)]
|
||||
pub crawl_files: Option<u64>,
|
||||
/// Of those, how many no earlier crawl had seen.
|
||||
#[uniffi(default = None)]
|
||||
pub crawl_added: Option<u64>,
|
||||
/// Why the last attempt failed, until one succeeds.
|
||||
#[uniffi(default = None)]
|
||||
pub crawl_error: Option<String>,
|
||||
}
|
||||
|
||||
fn share_record(share: Share) -> ShareRecord {
|
||||
@@ -148,6 +160,10 @@ fn share_record(share: Share) -> ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
crawled_at: None,
|
||||
crawl_files: None,
|
||||
crawl_added: None,
|
||||
crawl_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +203,16 @@ pub struct SyncRecord {
|
||||
pub uploaded: bool,
|
||||
/// Other installations whose history was read.
|
||||
pub devices: u32,
|
||||
/// Shares another device added or changed, configured here and not yet
|
||||
/// crawled. Crawl each.
|
||||
pub shares_added: Vec<String>,
|
||||
/// Shares another device removed, gone here with their offline files.
|
||||
/// Drop any download state kept for them.
|
||||
pub shares_removed: Vec<String>,
|
||||
/// Whether settings changed: repaint, and re-read the metadata settings.
|
||||
pub settings_changed: bool,
|
||||
/// Hand-picked matches and per-show track choices taken.
|
||||
pub titles: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
@@ -291,6 +317,17 @@ pub enum TitleNamesChoice {
|
||||
Romaji,
|
||||
}
|
||||
|
||||
/// The shared `pstr_core::browse::Filter`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum LibraryFilter {
|
||||
All,
|
||||
Series,
|
||||
Films,
|
||||
Watching,
|
||||
Unwatched,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// The shared `pstr_core::browse::Sort`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum LibrarySort {
|
||||
@@ -499,6 +536,11 @@ pub struct TitleRecord {
|
||||
/// Every title of its franchise, in release order, its own key included;
|
||||
/// empty for a title that stands alone.
|
||||
pub franchise: Vec<String>,
|
||||
/// Where Continue watching picks this title up, as an index into its
|
||||
/// episodes in display order: the part-watched one, or the one after the
|
||||
/// episode finished last. `None` keeps it off the shelf. See
|
||||
/// `pstr_core::library::Title::continue_with`.
|
||||
pub continue_index: Option<u32>,
|
||||
}
|
||||
|
||||
/// Playback preferences that outlive one file, one title and one launch.
|
||||
@@ -704,6 +746,22 @@ impl<T> Drop for AbortOnDrop<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a link could not be added, in words a viewer can act on.
|
||||
///
|
||||
/// A link that is already in the library and one that is not a link at all
|
||||
/// say so themselves; anything else failed at the handshake, where a wrong
|
||||
/// custom password and an expired link look the same from here.
|
||||
fn add_share_failure(error: &pstr_core::Error) -> String {
|
||||
match error {
|
||||
pstr_core::Error::Config(reason) => reason.clone(),
|
||||
pstr_core::Error::Drive(_) => format!(
|
||||
"Could not open that link: {error}. Check that it is complete, \
|
||||
still shared, and that the password is right."
|
||||
),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the
|
||||
/// caller stops waiting. Awaits to exactly what `JoinHandle` does.
|
||||
fn spawned<T: Send + 'static>(
|
||||
@@ -916,6 +974,8 @@ pub struct AndroidEngine {
|
||||
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
|
||||
/// The whole library as bridge records, behind the catalog's write counter.
|
||||
library_cache: Mutex<Option<CachedLibrary>>,
|
||||
/// Advanced by [`AndroidEngine::stop_crawl`]; a crawl watching it stops.
|
||||
crawl_stop: tokio::sync::watch::Sender<u64>,
|
||||
/// Serializes offline publication with share removal cleanup.
|
||||
share_publication: Mutex<()>,
|
||||
/// Advances after every successful share-store mutation. A connection is
|
||||
@@ -923,12 +983,6 @@ pub struct AndroidEngine {
|
||||
share_generation: AtomicU64,
|
||||
/// How many Proton thumbnails may be in flight at once.
|
||||
thumbnails: tokio::sync::Semaphore,
|
||||
/// When each share's visitor session was last refreshed.
|
||||
///
|
||||
/// Sessions are short-lived and the app is routinely backgrounded for hours
|
||||
/// between one episode and the next, so age — not just a share-store
|
||||
/// mutation — is a reason to reauthenticate before opening anything.
|
||||
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
||||
/// The streaming cache's budget in bytes, for the next connection and,
|
||||
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||
cache_budget: AtomicU64,
|
||||
@@ -956,13 +1010,6 @@ const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
|
||||
/// How often a download waiting for a slot asks whether it was cancelled.
|
||||
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// How long a visitor session is assumed good for without a refresh.
|
||||
///
|
||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||
/// failure that, before this, the viewer could only clear with a manual
|
||||
/// pull-to-refresh.
|
||||
const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
#[uniffi::export]
|
||||
impl AndroidEngine {
|
||||
#[uniffi::constructor]
|
||||
@@ -1005,10 +1052,10 @@ impl AndroidEngine {
|
||||
connecting: tokio::sync::Mutex::new(()),
|
||||
reusable_clients: Mutex::new(None),
|
||||
library_cache: Mutex::new(None),
|
||||
crawl_stop: tokio::sync::watch::channel(0).0,
|
||||
share_publication: Mutex::new(()),
|
||||
share_generation: AtomicU64::new(0),
|
||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||
session_refreshed: Mutex::new(HashMap::new()),
|
||||
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
||||
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
|
||||
accounts,
|
||||
@@ -1143,12 +1190,31 @@ impl AndroidEngine {
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
match sync.sync(&account, &self.catalog).await {
|
||||
Ok(report) => Ok(Some(SyncRecord {
|
||||
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
|
||||
uploaded: report.uploaded,
|
||||
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
|
||||
})),
|
||||
match sync.sync(&account, &self.catalog, &self.store).await {
|
||||
Ok(report) => {
|
||||
let profile = report.profile;
|
||||
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
|
||||
self.invalidate_connection(id);
|
||||
}
|
||||
for id in &profile.shares_removed {
|
||||
if let Err(error) = self.forget_synced_share(id) {
|
||||
log::warn!("files of {id}, a share removed elsewhere: {error}");
|
||||
}
|
||||
}
|
||||
Ok(Some(SyncRecord {
|
||||
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
|
||||
uploaded: report.uploaded,
|
||||
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
|
||||
shares_added: profile
|
||||
.shares_added
|
||||
.into_iter()
|
||||
.chain(profile.shares_changed)
|
||||
.collect(),
|
||||
shares_removed: profile.shares_removed,
|
||||
settings_changed: profile.settings,
|
||||
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
|
||||
}))
|
||||
}
|
||||
Err(error) if error.is_signed_out() => {
|
||||
let _ = self.accounts.sign_out(None).await;
|
||||
self.signed_out();
|
||||
@@ -1223,25 +1289,82 @@ impl AndroidEngine {
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
||||
self.store
|
||||
.list()
|
||||
.map_err(BridgeError::from_display)
|
||||
.map(|shares| shares.into_iter().map(share_record).collect())
|
||||
/// Configured shares due a crawl: never crawled here — brought in by sync,
|
||||
/// possibly while the app was in the background, or added and never
|
||||
/// crawled to the end — or not looked at for
|
||||
/// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`], so a share grows
|
||||
/// without anyone pressing Refresh. Crawl each.
|
||||
pub fn uncrawled_shares(&self) -> Result<Vec<String>, BridgeError> {
|
||||
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
||||
let crawls = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let now = now();
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.filter(|share| {
|
||||
crawls.get(&share.id).is_none_or(|record| {
|
||||
record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS)
|
||||
})
|
||||
})
|
||||
.map(|share| share.id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn add_share(
|
||||
&self,
|
||||
/// Every configured share, with what its last crawl here came to.
|
||||
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
||||
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
||||
let crawls = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.map(|share| {
|
||||
let crawl = crawls.get(&share.id).cloned();
|
||||
let mut record = share_record(share);
|
||||
if let Some(crawl) = crawl {
|
||||
record.crawled_at = (crawl.crawled_at > 0).then_some(crawl.crawled_at);
|
||||
record.crawl_files = (crawl.crawled_at > 0).then_some(crawl.files);
|
||||
record.crawl_added = (crawl.crawled_at > 0).then_some(crawl.added);
|
||||
record.crawl_error = crawl.error;
|
||||
}
|
||||
record
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Add a public link, once it has opened with the password given. A link
|
||||
/// that will not open is refused and leaves nothing behind. Crawl the
|
||||
/// returned share next.
|
||||
pub async fn add_share(
|
||||
self: Arc<Self>,
|
||||
name: String,
|
||||
url: String,
|
||||
custom_password: Option<String>,
|
||||
) -> Result<ShareRecord, BridgeError> {
|
||||
let share = self
|
||||
.store
|
||||
.add(&name, &url, custom_password.as_deref())
|
||||
.map_err(BridgeError::from_display)?;
|
||||
self.invalidate_connection(&share.id);
|
||||
Ok(share_record(share))
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
spawned(&runtime, async move {
|
||||
let (share, client) = self
|
||||
.store
|
||||
.add_verified(&name, &url, custom_password.as_deref())
|
||||
.await
|
||||
.map_err(|error| BridgeError::Failure {
|
||||
reason: add_share_failure(&error),
|
||||
})?;
|
||||
self.invalidate_connection(&share.id);
|
||||
// The handshake that proved the link is the one the crawl uses.
|
||||
self.reusable_clients
|
||||
.lock()
|
||||
.get_or_insert_with(Default::default)
|
||||
.insert(share.id.clone(), client);
|
||||
Ok(share_record(share))
|
||||
})
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Re-supply the link behind a share whose stored secret is unreadable.
|
||||
@@ -1263,6 +1386,14 @@ impl AndroidEngine {
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
/// Call a share something else; see `ShareStore::rename`.
|
||||
pub fn rename_share(&self, share_id: String, name: String) -> Result<ShareRecord, BridgeError> {
|
||||
self.store
|
||||
.rename(&share_id, &name)
|
||||
.map(share_record)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
|
||||
let _publication = self.share_publication.lock();
|
||||
let catalog = self.catalog.lock();
|
||||
@@ -1291,28 +1422,7 @@ impl AndroidEngine {
|
||||
// when secret cleanup fails, old authenticated clients and catalog
|
||||
// rows must not remain usable.
|
||||
self.invalidate_connection(&share_id);
|
||||
|
||||
for ((stored_share_id, link_id), file) in offline {
|
||||
if stored_share_id == share_id {
|
||||
let path = self
|
||||
.dirs
|
||||
.offline_file(&share_id, &link_id, &file.revision_id);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(BridgeError::from_display(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for link_id in links {
|
||||
let (partial, marker) = self.partial_paths(&share_id, &link_id);
|
||||
let _ = std::fs::remove_file(partial);
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
self.catalog
|
||||
.lock()
|
||||
.remove_share(&share_id)
|
||||
.map_err(BridgeError::from_display)?;
|
||||
self.forget_share_files(&share_id, offline, links)?;
|
||||
store_result.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
@@ -1369,19 +1479,31 @@ impl AndroidEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// The library grid for `search`, in `sort` order, each franchise folded
|
||||
/// into one tile when `grouped` — and the shelves above it. Keys into
|
||||
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
|
||||
/// once.
|
||||
/// The library grid for `search`, narrowed by `filter`, in `sort` order,
|
||||
/// each franchise folded into one tile when `grouped` — and the shelves
|
||||
/// above it. Keys into [`AndroidEngine::library`]'s titles, so the records
|
||||
/// cross the bridge once.
|
||||
pub fn arrangement(
|
||||
&self,
|
||||
search: Option<String>,
|
||||
filter: LibraryFilter,
|
||||
sort: LibrarySort,
|
||||
grouped: bool,
|
||||
) -> Result<ArrangementRecord, BridgeError> {
|
||||
let query = search.unwrap_or_default();
|
||||
let filter = filter_choice(filter);
|
||||
self.with_library(|cached| {
|
||||
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query);
|
||||
let titles: Vec<&Title> =
|
||||
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
||||
.into_iter()
|
||||
.filter(|title| {
|
||||
filter.admits(title, |node| {
|
||||
cached
|
||||
.offline
|
||||
.contains(&(node.share_id.clone(), node.link_id.clone()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let tiles = pstr_core::browse::arrange(
|
||||
&titles,
|
||||
pstr_core::browse::Order {
|
||||
@@ -1392,7 +1514,9 @@ impl AndroidEngine {
|
||||
added: &cached.added,
|
||||
},
|
||||
);
|
||||
let shelves = if query.trim().is_empty() {
|
||||
// Shelves are the whole library's, and only when it is shown
|
||||
// whole.
|
||||
let shelves = if query.trim().is_empty() && filter == Filter::All {
|
||||
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
|
||||
.into_iter()
|
||||
.map(|shelf| ShelfRecord {
|
||||
@@ -1553,6 +1677,37 @@ impl AndroidEngine {
|
||||
.map(|state| state.map(watch_state_record))
|
||||
}
|
||||
|
||||
/// Put one file's watch state back exactly as [`Self::watch_state`] read
|
||||
/// it — what Undo does. Unlike [`Self::save_watch_state`] it keeps the
|
||||
/// original time, so an undone change sorts where it did before. `None`,
|
||||
/// a file that had never been played, becomes a reset older than anything,
|
||||
/// which every later state outranks.
|
||||
pub fn restore_watch_state(
|
||||
&self,
|
||||
share_id: String,
|
||||
link_id: String,
|
||||
state: Option<WatchStateRecord>,
|
||||
) -> Result<(), BridgeError> {
|
||||
let state = state.map_or(
|
||||
WatchState {
|
||||
position_secs: 0.0,
|
||||
duration_secs: None,
|
||||
watched: false,
|
||||
updated_at: 0,
|
||||
},
|
||||
|state| WatchState {
|
||||
position_secs: state.position_secs,
|
||||
duration_secs: state.duration_secs,
|
||||
watched: state.watched,
|
||||
updated_at: state.updated_at,
|
||||
},
|
||||
);
|
||||
self.catalog
|
||||
.lock()
|
||||
.set_watch_state(&share_id, &link_id, &state)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
pub fn save_watch_state(
|
||||
&self,
|
||||
share_id: String,
|
||||
@@ -1817,6 +1972,47 @@ impl AndroidEngine {
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Fetch the blocks an episode opens with into the cache, without playing
|
||||
/// it: the next episode, near the end of this one, so that autoplay or
|
||||
/// "Play now" starts from the cache rather than from two round trips.
|
||||
///
|
||||
/// Quietly nothing for a downloaded episode, and on any failure: all a
|
||||
/// failure costs is the head start.
|
||||
pub async fn warm_episode(
|
||||
self: Arc<Self>,
|
||||
share_id: String,
|
||||
volume_id: String,
|
||||
link_id: String,
|
||||
) {
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
let warmed = spawned(&runtime, async move {
|
||||
let offline = self
|
||||
.catalog
|
||||
.lock()
|
||||
.offline_file(&share_id, &link_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if offline {
|
||||
return;
|
||||
}
|
||||
let Ok(connection) = self.connection().await else {
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.open_from_source(&connection, &share_id, &node_uid(&volume_id, &link_id))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => stream.warm_edges(),
|
||||
Err(error) => log::debug!("warming the next episode failed: {error}"),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = warmed {
|
||||
log::debug!("warming the next episode stopped: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_episode(
|
||||
self: Arc<Self>,
|
||||
share_id: String,
|
||||
@@ -1898,9 +2094,66 @@ impl AndroidEngine {
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Stop every crawl running now. Shares already listed stay stored; the
|
||||
/// one being listed keeps what it had. The stopped `crawl` returns
|
||||
/// without an error, since whoever stopped it knows why.
|
||||
pub fn stop_crawl(&self) {
|
||||
self.crawl_stop.send_modify(|generation| *generation += 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl AndroidEngine {
|
||||
/// Delete a removed share's offline and partial files, then its catalog
|
||||
/// rows. Watch state stays; see `Catalog::remove_share`.
|
||||
fn forget_share_files(
|
||||
&self,
|
||||
share_id: &str,
|
||||
offline: HashMap<(String, String), OfflineFile>,
|
||||
links: Vec<String>,
|
||||
) -> Result<(), BridgeError> {
|
||||
for ((stored_share_id, link_id), file) in offline {
|
||||
if stored_share_id == share_id {
|
||||
let path = self
|
||||
.dirs
|
||||
.offline_file(share_id, &link_id, &file.revision_id);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(BridgeError::from_display(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for link_id in links {
|
||||
let (partial, marker) = self.partial_paths(share_id, &link_id);
|
||||
let _ = std::fs::remove_file(partial);
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
self.catalog
|
||||
.lock()
|
||||
.remove_share(share_id)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
/// [`Self::forget_share_files`] for a share another device removed, which
|
||||
/// sync has already taken out of the configuration.
|
||||
fn forget_synced_share(&self, share_id: &str) -> Result<(), BridgeError> {
|
||||
let _publication = self.share_publication.lock();
|
||||
let catalog = self.catalog.lock();
|
||||
let offline = catalog
|
||||
.all_offline_files()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let links: Vec<String> = catalog
|
||||
.files(share_id)
|
||||
.map_err(BridgeError::from_display)?
|
||||
.into_iter()
|
||||
.map(|node| node.link_id)
|
||||
.collect();
|
||||
drop(catalog);
|
||||
self.invalidate_connection(share_id);
|
||||
self.forget_share_files(share_id, offline, links)
|
||||
}
|
||||
|
||||
/// One enrichment pass over the whole library.
|
||||
///
|
||||
/// What to do per title is decided by `pstr_meta::service::plan`, the same
|
||||
@@ -2067,6 +2320,7 @@ impl AndroidEngine {
|
||||
metadata,
|
||||
added,
|
||||
titles,
|
||||
offline: offline.into_keys().collect(),
|
||||
};
|
||||
let answer = read(&cached);
|
||||
*self.library_cache.lock() = Some(cached);
|
||||
@@ -2171,7 +2425,6 @@ impl AndroidEngine {
|
||||
}
|
||||
}
|
||||
drop(reusable);
|
||||
self.session_refreshed.lock().remove(share_id);
|
||||
self.share_generation.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
@@ -2335,48 +2588,31 @@ impl AndroidEngine {
|
||||
uid: &NodeUid,
|
||||
copy: bool,
|
||||
) -> Result<VideoStream, BridgeError> {
|
||||
let open = || async {
|
||||
if copy {
|
||||
connection.source.open_for_copy(share_id, uid).await
|
||||
} else {
|
||||
connection.source.open(share_id, uid).await
|
||||
}
|
||||
};
|
||||
let stale = self
|
||||
.session_refreshed
|
||||
.lock()
|
||||
.get(share_id)
|
||||
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR);
|
||||
if stale {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
}
|
||||
match open().await {
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(_) if !stale => {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
open().await.map_err(BridgeError::from_display)
|
||||
}
|
||||
Err(error) => Err(BridgeError::from_display(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: a refresh that fails leaves the open to report the real
|
||||
/// problem, which is more useful than a refresh error standing in for it.
|
||||
async fn refresh_session(&self, connection: &Connection, share_id: &str) {
|
||||
if connection.library.refresh_session(share_id).await.is_ok() {
|
||||
self.session_refreshed
|
||||
.lock()
|
||||
.insert(share_id.to_owned(), std::time::Instant::now());
|
||||
}
|
||||
connection
|
||||
.library
|
||||
.reauthenticating(share_id, || async {
|
||||
if copy {
|
||||
connection.source.open_for_copy(share_id, uid).await
|
||||
} else {
|
||||
connection.source.open(share_id, uid).await
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
|
||||
let mut stop = self.crawl_stop.subscribe();
|
||||
stop.borrow_and_update();
|
||||
let connection = self.connection().await?;
|
||||
let targets: Vec<String> = match share_id {
|
||||
Some(id) => vec![id],
|
||||
None => connection.library.share_ids().map(str::to_owned).collect(),
|
||||
};
|
||||
for id in targets {
|
||||
if stop.has_changed().unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
connection
|
||||
.library
|
||||
.refresh_session(&id)
|
||||
@@ -2384,19 +2620,30 @@ impl AndroidEngine {
|
||||
.map_err(|error| BridgeError::Failure {
|
||||
reason: format!("refresh session for {id}: {error}"),
|
||||
})?;
|
||||
self.session_refreshed
|
||||
.lock()
|
||||
.insert(id.clone(), std::time::Instant::now());
|
||||
let before = self
|
||||
.catalog
|
||||
.lock()
|
||||
.all_offline_files()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let nodes = connection
|
||||
.library
|
||||
.crawl(&id)
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let crawled = tokio::select! {
|
||||
crawled = connection.library.crawl(&id) => crawled,
|
||||
_ = stop.changed() => return Ok(()),
|
||||
};
|
||||
let nodes = match crawled {
|
||||
Ok(nodes) => nodes,
|
||||
Err(error) => {
|
||||
// On the share's row until a crawl succeeds: a link revoked
|
||||
// upstream otherwise just stops growing.
|
||||
if let Err(error) = self
|
||||
.catalog
|
||||
.lock()
|
||||
.record_crawl_failure(&id, &error.to_string())
|
||||
{
|
||||
log::warn!("record a failed crawl: {error}");
|
||||
}
|
||||
return Err(BridgeError::from_display(error));
|
||||
}
|
||||
};
|
||||
let rows = build_rows(&id, &nodes);
|
||||
self.catalog
|
||||
.lock()
|
||||
@@ -2464,8 +2711,35 @@ impl AndroidEngine {
|
||||
}
|
||||
|
||||
let connection = self.connection().await?;
|
||||
self.open_from_source(&connection, share_id, &node_uid(volume_id, link_id))
|
||||
.await
|
||||
let stream = self
|
||||
.open_from_source(&connection, share_id, &node_uid(volume_id, link_id))
|
||||
.await?;
|
||||
// Before mpv has the handle: building its core and surface takes a
|
||||
// moment, and the network can spend that moment on the two reads
|
||||
// every file opens with — and, resuming, on the one it seeks to.
|
||||
match self.resume_fraction(share_id, link_id) {
|
||||
Some(fraction) => stream.warm_resume(fraction),
|
||||
None => stream.warm_edges(),
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// How far into the file an episode resumes, as a fraction of its
|
||||
/// running time, from what was saved the last time it played. `None` for
|
||||
/// one that starts from the beginning, as the clients do for a finished
|
||||
/// episode or one barely begun.
|
||||
fn resume_fraction(&self, share_id: &str, link_id: &str) -> Option<f64> {
|
||||
let state = self
|
||||
.catalog
|
||||
.lock()
|
||||
.watch_state(share_id, link_id)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
if state.watched || state.position_secs < pstr_core::library::RESUME_FLOOR_SECS {
|
||||
return None;
|
||||
}
|
||||
let duration = state.duration_secs.filter(|duration| *duration > 0.0)?;
|
||||
Some(state.position_secs / duration)
|
||||
}
|
||||
|
||||
async fn download_episode_inner(
|
||||
@@ -2920,6 +3194,8 @@ struct CachedLibrary {
|
||||
/// When each title last gained a file.
|
||||
added: HashMap<String, i64>,
|
||||
titles: Vec<TitleRecord>,
|
||||
/// The files kept on this device, by share and link id.
|
||||
offline: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// How many provider lookups may be in flight at once.
|
||||
@@ -2981,6 +3257,17 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_choice(filter: LibraryFilter) -> Filter {
|
||||
match filter {
|
||||
LibraryFilter::All => Filter::All,
|
||||
LibraryFilter::Series => Filter::Series,
|
||||
LibraryFilter::Films => Filter::Films,
|
||||
LibraryFilter::Watching => Filter::Watching,
|
||||
LibraryFilter::Unwatched => Filter::Unwatched,
|
||||
LibraryFilter::Downloaded => Filter::Downloaded,
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_choice(sort: LibrarySort) -> Sort {
|
||||
match sort {
|
||||
LibrarySort::Name => Sort::Name,
|
||||
@@ -3133,6 +3420,10 @@ fn title_record(
|
||||
next_episode: None,
|
||||
next_airing_at: None,
|
||||
franchise: Vec::new(),
|
||||
continue_index: title.continue_with().and_then(|episode| {
|
||||
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
|
||||
u32::try_from(at).ok()
|
||||
}),
|
||||
seasons: title
|
||||
.seasons
|
||||
.iter()
|
||||
@@ -3728,7 +4019,12 @@ mod tests {
|
||||
}
|
||||
|
||||
let arranged = engine
|
||||
.arrangement(None, super::LibrarySort::Name, true)
|
||||
.arrangement(
|
||||
None,
|
||||
super::LibraryFilter::All,
|
||||
super::LibrarySort::Name,
|
||||
true,
|
||||
)
|
||||
.expect("arrange");
|
||||
assert_eq!(arranged.tiles.len(), 1);
|
||||
assert_eq!(arranged.tiles[0].key, "sousou no frieren");
|
||||
@@ -3765,13 +4061,13 @@ mod tests {
|
||||
)
|
||||
.expect("engine");
|
||||
|
||||
// Stored directly: `add_share` opens the link first, and a test has
|
||||
// no network to open it over.
|
||||
let share = engine
|
||||
.add_share(
|
||||
"test".to_owned(),
|
||||
"https://drive.proton.me/urls/ABC123#s3cr3t".to_owned(),
|
||||
None,
|
||||
)
|
||||
.store
|
||||
.add("test", "https://drive.proton.me/urls/ABC123#s3cr3t", None)
|
||||
.expect("add share");
|
||||
engine.invalidate_connection(&share.id);
|
||||
assert_eq!(engine.share_generation.load(Ordering::Acquire), 1);
|
||||
assert!(
|
||||
engine
|
||||
|
||||
@@ -11,6 +11,9 @@ 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];
|
||||
|
||||
/// What the settings page offers for the size of everything, in percent.
|
||||
pub const TEXT_SIZE_CHOICES: [u16; 5] = [90, 100, 115, 130, 150];
|
||||
|
||||
#[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.
|
||||
@@ -21,6 +24,15 @@ pub struct DesktopPrefs {
|
||||
pub hardware_decoding: bool,
|
||||
/// How much decrypted video the disk cache may keep.
|
||||
pub cache_gib: u32,
|
||||
/// How the library was last arranged, so it opens that way.
|
||||
pub filter: crate::app::Filter,
|
||||
pub sort: pstr_core::browse::Sort,
|
||||
pub grouped: bool,
|
||||
/// The library's offer of posters and synopses was turned down.
|
||||
pub metadata_offer_dismissed: bool,
|
||||
/// How large text and controls are drawn, in percent. Per computer, not
|
||||
/// synced: what reads well depends on the screen and how far away it is.
|
||||
pub text_size: u16,
|
||||
}
|
||||
|
||||
impl Default for DesktopPrefs {
|
||||
@@ -28,11 +40,23 @@ impl Default for DesktopPrefs {
|
||||
Self {
|
||||
hardware_decoding: true,
|
||||
cache_gib: 4,
|
||||
filter: crate::app::Filter::default(),
|
||||
sort: pstr_core::browse::Sort::default(),
|
||||
// On: a share holding a series and its three films is four tiles
|
||||
// of the same picture otherwise.
|
||||
grouped: true,
|
||||
metadata_offer_dismissed: false,
|
||||
text_size: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopPrefs {
|
||||
/// [`Self::text_size`] as egui's zoom factor, kept to what is offered.
|
||||
pub fn zoom(&self) -> f32 {
|
||||
f32::from(self.text_size.clamp(50, 200)) / 100.0
|
||||
}
|
||||
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||
}
|
||||
@@ -93,6 +117,17 @@ mod tests {
|
||||
assert!(prefs.hardware_decoding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_size_from_an_older_file_is_full_size_and_a_wild_one_is_kept_in_range() {
|
||||
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
|
||||
assert_eq!(prefs.zoom(), 1.0);
|
||||
let wild = DesktopPrefs {
|
||||
text_size: 900,
|
||||
..DesktopPrefs::default()
|
||||
};
|
||||
assert_eq!(wild.zoom(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_file_is_never_overwritten() {
|
||||
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||
|
||||
@@ -34,6 +34,7 @@ use pstr_core::config::AppDirs;
|
||||
use pstr_core::library::{Library, Title, TitleKind};
|
||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_core::profile::ProfileChanges;
|
||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
@@ -176,7 +177,11 @@ pub enum Event {
|
||||
/// The share the form asked for is stored; its crawl is starting.
|
||||
ShareAdded(String),
|
||||
/// The share the form asked for was refused, with why. Nothing was crawled.
|
||||
ShareRejected(String),
|
||||
ShareRejected {
|
||||
error: String,
|
||||
/// The link has a password of its own and none was given.
|
||||
needs_password: bool,
|
||||
},
|
||||
/// Shares opened; playback is possible from here.
|
||||
Connected {
|
||||
/// Shares that did not open, with why.
|
||||
@@ -192,8 +197,15 @@ pub enum Event {
|
||||
share_id: String,
|
||||
nodes: usize,
|
||||
files: usize,
|
||||
/// Files no earlier crawl had seen.
|
||||
added: u64,
|
||||
seconds: f64,
|
||||
/// Started by the app rather than the viewer, so only news is worth
|
||||
/// saying.
|
||||
quiet: bool,
|
||||
},
|
||||
/// What the last crawl of each share came to, by share id.
|
||||
Crawls(HashMap<String, pstr_core::catalog::CrawlRecord>),
|
||||
/// A crawl is under way: which share, and how many nodes so far.
|
||||
CrawlProgress { share: String, found: usize },
|
||||
/// Every requested crawl is done.
|
||||
@@ -215,12 +227,16 @@ pub enum Event {
|
||||
EpisodeMetadata(HashMap<String, EpisodeGuide>),
|
||||
/// The enrichment settings changed, on disk.
|
||||
MetadataConfig(MetadataConfig),
|
||||
/// Another device's appearance arrived through sync.
|
||||
Appearance(Appearance),
|
||||
/// A matching run finished, with how it went.
|
||||
Matched {
|
||||
matched: usize,
|
||||
unmatched: usize,
|
||||
failed: usize,
|
||||
},
|
||||
/// A matching run has work to do — one asked for, or one after a crawl.
|
||||
MatchStarted,
|
||||
/// Every requested match is done.
|
||||
MatchFinished,
|
||||
/// What the provider answered a hand search with, unscored and in its own
|
||||
@@ -279,7 +295,7 @@ pub enum Event {
|
||||
SignInFailed(String),
|
||||
/// Watch history synced with the account's Drive.
|
||||
Synced(SyncReport),
|
||||
/// Watch history did not sync, with why. Not a toast: it is retried on a
|
||||
/// Sync did not run, with why. Not a toast: it is retried on a
|
||||
/// timer, and an offline evening would be a stack of them.
|
||||
SyncFailed(String),
|
||||
/// The account's top-level places, for the Drive browser.
|
||||
@@ -293,6 +309,16 @@ pub enum Event {
|
||||
BrowseFailed(String),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The add form's refusal of `error`.
|
||||
fn rejected(error: &pstr_core::Error) -> Self {
|
||||
Self::ShareRejected {
|
||||
error: error.to_string(),
|
||||
needs_password: matches!(error, pstr_core::Error::PasswordNeeded),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Proton account stands.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum AccountStatus {
|
||||
@@ -397,6 +423,14 @@ const DOWNLOAD_CONCURRENCY: usize = 3;
|
||||
/// launch and whenever a player stops.
|
||||
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Unix seconds, now.
|
||||
pub fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|since| since.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Enrichment as currently configured.
|
||||
struct Enrichment {
|
||||
config: MetadataConfig,
|
||||
@@ -640,20 +674,63 @@ impl Engine {
|
||||
}
|
||||
|
||||
/// Add a share, then open and crawl it — which is what "add" means to
|
||||
/// someone who just pasted a link.
|
||||
/// someone who just pasted a link. A link that will not open is refused
|
||||
/// before anything is stored, so trying again is not "already configured".
|
||||
pub fn add_share(&self, name: String, url: String, password: Option<String>) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let share = match engine.store.add(&name, &url, password.as_deref()) {
|
||||
Ok(share) => share,
|
||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||
let share = match engine
|
||||
.store
|
||||
.add_verified(&name, &url, password.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok((share, _)) => share,
|
||||
Err(error) => return engine.emit(Event::rejected(&error)),
|
||||
};
|
||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||
engine.load_shares();
|
||||
// The open connection predates the share, and crawling it through
|
||||
// that would find it "not open".
|
||||
engine.connection.lock().take();
|
||||
engine.connect_and_crawl(Some(share.id)).await;
|
||||
engine.connect_and_crawl(Some(vec![share.id]), false).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Call a share something else. See [`ShareStore::rename`].
|
||||
pub fn rename_share(&self, id: String, name: String) {
|
||||
let engine = self.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || match engine.store.rename(&id, &name) {
|
||||
Ok(_) => engine.load_shares(),
|
||||
Err(error) => engine.fail("rename the share", error),
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-supply the link behind a share whose stored secret is unreadable —
|
||||
/// a keyring that was reset — or whose custom password changed, keeping
|
||||
/// its catalog, watch state and downloads. See
|
||||
/// [`ShareStore::replace_secrets`]. Then crawl it, which is what proves
|
||||
/// the new secret works.
|
||||
pub fn repair_share(&self, id: String, url: String, password: Option<String>) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let share = match engine.store.replace_secrets(&id, &url, password.as_deref()) {
|
||||
Ok(share) => share,
|
||||
Err(error) => {
|
||||
// The add form is not where this was asked, and nothing
|
||||
// will be crawled to say the crawl is over.
|
||||
engine.emit(Event::CrawlFinished);
|
||||
return engine.fail("update the link", error);
|
||||
}
|
||||
};
|
||||
engine.emit(Event::Status(format!(
|
||||
"updated the link for {}",
|
||||
share.name
|
||||
)));
|
||||
engine.load_shares();
|
||||
// Its client was opened with the old secret.
|
||||
engine.connection.lock().take();
|
||||
engine.connect_and_crawl(Some(vec![share.id]), false).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -667,12 +744,12 @@ impl Engine {
|
||||
};
|
||||
let share = match engine.store.add_folder(&name, folder) {
|
||||
Ok(share) => share,
|
||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||
Err(error) => return engine.emit(Event::rejected(&error)),
|
||||
};
|
||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||
engine.load_shares();
|
||||
engine.connection.lock().take();
|
||||
engine.connect_and_crawl(Some(share.id)).await;
|
||||
engine.connect_and_crawl(Some(vec![share.id]), false).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -836,9 +913,10 @@ impl Engine {
|
||||
};
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
match sync.sync(&account, &engine.catalog).await {
|
||||
match sync.sync(&account, &engine.catalog, &engine.store).await {
|
||||
Ok(report) => {
|
||||
if report.applied > 0 {
|
||||
engine.take_synced_profile(&report.profile).await;
|
||||
if report.applied > 0 || report.profile.titles > 0 {
|
||||
engine.load_library();
|
||||
}
|
||||
engine.emit(Event::Synced(report));
|
||||
@@ -875,8 +953,9 @@ impl Engine {
|
||||
return;
|
||||
};
|
||||
let catalog = Arc::clone(&self.catalog);
|
||||
let store = Arc::clone(&self.store);
|
||||
self.runtime.block_on(async move {
|
||||
match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await {
|
||||
match tokio::time::timeout(timeout, sync.sync(&account, &catalog, &store)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
||||
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
|
||||
@@ -884,6 +963,58 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
/// Act on what sync brought from other devices: reread the settings it
|
||||
/// rewrote, crawl the shares it added, drop the ones it removed.
|
||||
async fn take_synced_profile(&self, profile: &ProfileChanges) {
|
||||
if profile.settings {
|
||||
match pstr_core::prefs::load(&self.dirs) {
|
||||
// The volume is this device's and was not synced; the file
|
||||
// still holds it.
|
||||
Ok(prefs) => *self.prefs.lock() = prefs,
|
||||
Err(error) => tracing::warn!("reread the playback preferences: {error}"),
|
||||
}
|
||||
match pstr_core::appearance::load(&self.dirs) {
|
||||
Ok(appearance) => {
|
||||
*self.appearance.lock() = appearance;
|
||||
self.emit(Event::Appearance(appearance));
|
||||
}
|
||||
Err(error) => tracing::warn!("reread the appearance: {error}"),
|
||||
}
|
||||
match pstr_meta::settings::load(&self.dirs) {
|
||||
Ok(config) => {
|
||||
let engine = self.clone();
|
||||
let _ = self
|
||||
.runtime
|
||||
.spawn_blocking(move || {
|
||||
engine.install_enrichment(Enrichment::build(config.clone()), false);
|
||||
engine.emit(Event::MetadataConfig(config));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
|
||||
}
|
||||
}
|
||||
if profile.titles > 0 {
|
||||
match self.catalog.lock().all_title_track_prefs() {
|
||||
Ok(prefs) => *self.track_prefs.lock() = prefs,
|
||||
Err(error) => tracing::warn!("reread show track preferences: {error}"),
|
||||
}
|
||||
self.load_metadata();
|
||||
}
|
||||
for id in &profile.shares_removed {
|
||||
self.forget_share(id.clone(), false).await;
|
||||
}
|
||||
if !profile.shares() {
|
||||
return;
|
||||
}
|
||||
self.connection.lock().take();
|
||||
self.load_shares();
|
||||
self.load_library();
|
||||
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
|
||||
self.connect_and_crawl(Some(vec![id.clone()]), false).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_periodically(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
@@ -929,6 +1060,48 @@ impl Engine {
|
||||
pub fn remove_share(&self, id: String) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
engine.forget_share(id, true).await;
|
||||
engine.connection.lock().take();
|
||||
engine.load_library();
|
||||
engine.emit(Event::Status("removed".into()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget every share the catalog holds files for that the share list no
|
||||
/// longer names — what an older `pstr remove` left behind. Its files would
|
||||
/// otherwise sit in the library beside whatever replaced it, every episode
|
||||
/// twice.
|
||||
pub fn forget_orphaned_shares(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let configured = match engine.store.list() {
|
||||
Ok(shares) => shares,
|
||||
Err(error) => return engine.fail("read the share list", error),
|
||||
};
|
||||
let orphans: Vec<String> = match engine.catalog.lock().share_ids() {
|
||||
Ok(ids) => ids
|
||||
.into_iter()
|
||||
.filter(|id| !configured.iter().any(|share| &share.id == id))
|
||||
.collect(),
|
||||
Err(error) => return engine.fail("read the catalog", error),
|
||||
};
|
||||
if orphans.is_empty() {
|
||||
return;
|
||||
}
|
||||
for id in orphans {
|
||||
tracing::info!(share = %id, "forgetting the catalog rows of a removed share");
|
||||
engine.forget_share(id, false).await;
|
||||
}
|
||||
engine.load_library();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop a share's downloads and delete its files and catalog rows, and,
|
||||
/// when it is still `configured` — not already removed by sync — its
|
||||
/// entry and secrets.
|
||||
async fn forget_share(&self, id: String, configured: bool) {
|
||||
let engine = self;
|
||||
{
|
||||
let keys: Vec<_> = engine
|
||||
.downloads
|
||||
.lock()
|
||||
@@ -971,7 +1144,7 @@ impl Engine {
|
||||
if let Err(error) = engine.catalog.lock().remove_share(&id) {
|
||||
return engine.fail("drop that share's catalog rows", error);
|
||||
}
|
||||
if let Err(error) = engine.store.remove(&id) {
|
||||
if configured && let Err(error) = engine.store.remove(&id) {
|
||||
return engine.fail("remove that share", error);
|
||||
}
|
||||
{
|
||||
@@ -983,9 +1156,7 @@ impl Engine {
|
||||
engine.load_shares();
|
||||
// Its clients are stale now; the next action reopens what is left.
|
||||
engine.connection.lock().take();
|
||||
engine.load_library();
|
||||
engine.emit(Event::Status("removed".into()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ connection
|
||||
@@ -1173,18 +1344,65 @@ impl Engine {
|
||||
pub fn crawl(&self, share: Option<String>) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
engine.connect_and_crawl(share).await;
|
||||
engine
|
||||
.connect_and_crawl(share.map(|id| vec![id]), false)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_and_crawl(&self, share: Option<String>) {
|
||||
/// Crawl, quietly, every share no crawl has looked at for
|
||||
/// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`] — or ever.
|
||||
///
|
||||
/// Without this a share only grew when someone thought to press Refresh,
|
||||
/// so the episode that came out yesterday was not in the library until
|
||||
/// then. Quiet: a failure is logged and recorded on the share's row rather
|
||||
/// than toasted, and a crawl that found nothing new says nothing.
|
||||
pub fn crawl_stale(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let stale = {
|
||||
let crawls = match engine.catalog.lock().crawls() {
|
||||
Ok(crawls) => crawls,
|
||||
Err(error) => return tracing::warn!(%error, "read crawl records"),
|
||||
};
|
||||
let Ok(shares) = engine.store.list() else {
|
||||
return;
|
||||
};
|
||||
let now = unix_now();
|
||||
shares
|
||||
.into_iter()
|
||||
.filter(|share| {
|
||||
crawls.get(&share.id).is_none_or(|record| {
|
||||
record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS)
|
||||
})
|
||||
})
|
||||
.map(|share| share.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if !stale.is_empty() {
|
||||
engine.connect_and_crawl(Some(stale), true).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Read what the last crawl of each share came to, for the shares page.
|
||||
pub fn load_crawls(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || match engine.catalog.lock().crawls() {
|
||||
Ok(crawls) => engine.emit(Event::Crawls(crawls)),
|
||||
Err(error) => tracing::warn!(%error, "read crawl records"),
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_and_crawl(&self, shares: Option<Vec<String>>, quiet: bool) {
|
||||
let Some((library, _)) = self.ensure_connected().await else {
|
||||
self.emit(Event::CrawlFinished);
|
||||
return;
|
||||
};
|
||||
|
||||
let targets: Vec<String> = match share {
|
||||
Some(id) => vec![id],
|
||||
let targets: Vec<String> = match shares {
|
||||
Some(ids) => ids,
|
||||
None => library.share_ids().map(str::to_string).collect(),
|
||||
};
|
||||
|
||||
@@ -1194,10 +1412,36 @@ impl Engine {
|
||||
futures::future::join_all(
|
||||
targets
|
||||
.into_iter()
|
||||
.map(|share_id| self.crawl_share(&library, share_id, cancel.clone())),
|
||||
.map(|share_id| self.crawl_share(&library, share_id, cancel.clone(), quiet)),
|
||||
)
|
||||
.await;
|
||||
self.emit(Event::CrawlFinished);
|
||||
self.match_after_crawl().await;
|
||||
}
|
||||
|
||||
/// Look up whatever the crawl brought that has no answer yet — a new show,
|
||||
/// or a new episode the stored guide cannot name. Silent when there is
|
||||
/// nothing to ask, which is almost every time: `plan` makes no requests for
|
||||
/// a library that is already matched.
|
||||
async fn match_after_crawl(&self) {
|
||||
if !self.metadata_config().enabled {
|
||||
return;
|
||||
}
|
||||
let titles = {
|
||||
let catalog = self.catalog.lock();
|
||||
match catalog
|
||||
.all_files()
|
||||
.and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?)))
|
||||
{
|
||||
Ok(library) => library.titles,
|
||||
Err(error) => return self.fail("read the catalog", error),
|
||||
}
|
||||
};
|
||||
if titles.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.run_match(titles, false, true).await;
|
||||
self.emit(Event::MatchFinished);
|
||||
}
|
||||
|
||||
/// Stop every crawl still listing. Shares already past their listing
|
||||
@@ -1212,6 +1456,7 @@ impl Engine {
|
||||
library: &SharedLibrary,
|
||||
share_id: String,
|
||||
mut cancel: tokio::sync::watch::Receiver<u64>,
|
||||
quiet: bool,
|
||||
) {
|
||||
let started = std::time::Instant::now();
|
||||
let name = self
|
||||
@@ -1241,10 +1486,24 @@ impl Engine {
|
||||
// share's rows and its stale downloads, and stopping halfway through
|
||||
// that would leave the two disagreeing.
|
||||
let nodes = tokio::select! {
|
||||
result = library.crawl_reporting(&share_id, report) => match result {
|
||||
result = library.reauthenticating(&share_id, || {
|
||||
library.crawl_reporting(&share_id, &report)
|
||||
}) => match result {
|
||||
Ok(nodes) => nodes,
|
||||
Err(error) => {
|
||||
self.fail(&format!("crawl {share_id}"), error);
|
||||
if let Err(error) = self
|
||||
.catalog
|
||||
.lock()
|
||||
.record_crawl_failure(&share_id, &error.to_string())
|
||||
{
|
||||
tracing::warn!(%error, "record a failed crawl");
|
||||
}
|
||||
self.load_crawls();
|
||||
if quiet {
|
||||
tracing::warn!(%error, "crawl {name}");
|
||||
} else {
|
||||
self.fail(&format!("crawl {name}"), error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
@@ -1323,12 +1582,22 @@ impl Engine {
|
||||
return;
|
||||
}
|
||||
|
||||
let added = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.ok()
|
||||
.and_then(|crawls| crawls.get(&share_id).map(|record| record.added))
|
||||
.unwrap_or(0);
|
||||
self.emit(Event::Crawled {
|
||||
share_id,
|
||||
nodes: nodes.len(),
|
||||
files,
|
||||
added,
|
||||
seconds: started.elapsed().as_secs_f64(),
|
||||
quiet,
|
||||
});
|
||||
self.load_crawls();
|
||||
self.load_library();
|
||||
}
|
||||
|
||||
@@ -1419,19 +1688,26 @@ impl Engine {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some((_, source)) = engine.ensure_connected().await else {
|
||||
let Some((library, source)) = engine.ensure_connected().await else {
|
||||
return;
|
||||
};
|
||||
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||
match source.open(&target.share_id, &uid).await {
|
||||
let opened = library
|
||||
.reauthenticating(&target.share_id, || source.open(&target.share_id, &uid))
|
||||
.await;
|
||||
match opened {
|
||||
Ok(stream) if stream.size() == 0 => {
|
||||
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
||||
}
|
||||
Ok(stream) => {
|
||||
// Before mpv is even built: building it takes the UI thread
|
||||
// a moment, and the network can spend that moment on the
|
||||
// two reads every file opens with.
|
||||
stream.warm_edges();
|
||||
// two reads every file opens with — and, resuming, on the
|
||||
// one it is about to seek to.
|
||||
match engine.resume_fraction(&target) {
|
||||
Some(fraction) => stream.warm_resume(fraction),
|
||||
None => stream.warm_edges(),
|
||||
}
|
||||
engine.emit(Event::PlaybackReady {
|
||||
target: Box::new(target),
|
||||
stream,
|
||||
@@ -1442,6 +1718,20 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
/// How far into the file `target` resumes, as a fraction of its running
|
||||
/// time — known from the last time it played.
|
||||
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
|
||||
let at = target.resume_at?;
|
||||
let state = self
|
||||
.catalog
|
||||
.lock()
|
||||
.watch_state(&target.share_id, &target.link_id)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let duration = state.duration_secs.filter(|duration| *duration > 0.0)?;
|
||||
Some(at / duration)
|
||||
}
|
||||
|
||||
/// Fetch the blocks a file opens with into the cache, without playing it.
|
||||
///
|
||||
/// For the episode after the one playing, so that autoplay — or a click on
|
||||
@@ -1464,10 +1754,13 @@ impl Engine {
|
||||
if offline {
|
||||
return;
|
||||
}
|
||||
let Some(source) = engine.opened().map(|(_, source)| source) else {
|
||||
let Some((library, source)) = engine.opened() else {
|
||||
return;
|
||||
};
|
||||
match source.open(&share_id, &uid).await {
|
||||
let opened = library
|
||||
.reauthenticating(&share_id, || source.open(&share_id, &uid))
|
||||
.await;
|
||||
match opened {
|
||||
Ok(stream) => stream.warm_edges(),
|
||||
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
|
||||
}
|
||||
@@ -1800,14 +2093,14 @@ impl Engine {
|
||||
}
|
||||
};
|
||||
self.update_download(&key, |item| item.state = DownloadState::Running);
|
||||
let Some((_, source)) = self.ensure_connected().await else {
|
||||
let Some((library, source)) = self.ensure_connected().await else {
|
||||
anyhow::bail!("share is not connected");
|
||||
};
|
||||
let stream = source
|
||||
.open_for_copy(
|
||||
&target.share_id,
|
||||
&node_uid(&target.volume_id, &target.link_id),
|
||||
)
|
||||
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||
let stream = library
|
||||
.reauthenticating(&target.share_id, || {
|
||||
source.open_for_copy(&target.share_id, &uid)
|
||||
})
|
||||
.await?;
|
||||
let revision_id = stream.revision_id().to_owned();
|
||||
let block_sizes = stream.block_sizes().to_vec();
|
||||
@@ -2233,13 +2526,18 @@ impl Engine {
|
||||
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
engine.run_match(titles, force).await;
|
||||
engine.run_match(titles, force, false).await;
|
||||
engine.emit(Event::MatchFinished);
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_match(&self, titles: Vec<Title>, force: bool) {
|
||||
/// `automatic` is a run nobody asked for, after a crawl: it says nothing
|
||||
/// when there is nothing to do, or no provider to do it with.
|
||||
async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) {
|
||||
let Some(service) = self.metadata_service().await else {
|
||||
if automatic {
|
||||
return;
|
||||
}
|
||||
return self.emit(Event::Error(self.no_service_reason()));
|
||||
};
|
||||
let provider = service.provider();
|
||||
@@ -2264,8 +2562,12 @@ impl Engine {
|
||||
force,
|
||||
);
|
||||
if pending.is_empty() {
|
||||
if automatic {
|
||||
return;
|
||||
}
|
||||
return self.emit(Event::Status("everything is already matched".into()));
|
||||
}
|
||||
self.emit(Event::MatchStarted);
|
||||
self.emit(Event::Status(format!(
|
||||
"matching {} titles against {}…",
|
||||
pending.len(),
|
||||
|
||||
@@ -338,6 +338,18 @@ impl Playback {
|
||||
(end > self.position + 0.5).then_some((label, end))
|
||||
}
|
||||
|
||||
/// Whether the chapters say where the credits start. Most files that are
|
||||
/// not anime have no chapters at all.
|
||||
pub fn knows_credits(&self) -> bool {
|
||||
self.credits_at.is_some()
|
||||
}
|
||||
|
||||
/// Seconds of the file left to play, once its length is known.
|
||||
pub fn remaining(&self) -> Option<f64> {
|
||||
self.duration
|
||||
.map(|duration| (duration - self.position).max(0.0))
|
||||
}
|
||||
|
||||
/// Whether the playhead is inside the run of credits and preview that ends
|
||||
/// the file — what "the episode is over" means when a file states its
|
||||
/// chapters.
|
||||
@@ -504,7 +516,11 @@ fn run(
|
||||
orders: std::sync::mpsc::Receiver<Command>,
|
||||
finished: Arc<AtomicBool>,
|
||||
) {
|
||||
let handle = match player.play(stream) {
|
||||
let opened = match target.resume_at {
|
||||
Some(at) => player.play_from(stream, at),
|
||||
None => player.play(stream),
|
||||
};
|
||||
let handle = match opened {
|
||||
Ok(handle) => handle,
|
||||
Err(error) => {
|
||||
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
||||
@@ -517,7 +533,6 @@ fn run(
|
||||
let mut position = target.resume_at.unwrap_or(0.0);
|
||||
let mut duration: Option<f64> = None;
|
||||
let mut watched = false;
|
||||
let mut resumed = target.resume_at.is_none();
|
||||
let mut last_save = std::time::Instant::now();
|
||||
|
||||
'playback: loop {
|
||||
@@ -551,13 +566,6 @@ fn run(
|
||||
restore_tracks(&player, show);
|
||||
}
|
||||
emit_tracks(&engine, id, &player);
|
||||
// Only now is there a timeline to seek within.
|
||||
if !resumed && let Some(at) = target.resume_at {
|
||||
resumed = true;
|
||||
if let Err(error) = player.seek_to(at) {
|
||||
tracing::warn!("resume at {at}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerEvent::Duration(value) => duration = Some(*value),
|
||||
PlayerEvent::Position(value) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::app::Action;
|
||||
use crate::engine::AccountStatus;
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// What the account section holds between frames.
|
||||
#[derive(Default)]
|
||||
@@ -133,9 +134,10 @@ impl DriveBrowser {
|
||||
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Proton account");
|
||||
ui.label(ui::muted(
|
||||
"Optional. Signed in, your watch history is kept in your own Drive and every device \
|
||||
resumes where another left off, and folders of your Drive — your files, other \
|
||||
devices, what was shared with you — can be added to the library.",
|
||||
"Optional. Signed in, your shares, settings and watch history are kept in your own \
|
||||
Drive, so every device shows the same library and resumes where another left off, \
|
||||
and folders of your Drive — your files, other devices, what was shared with you — \
|
||||
can be added to the library.",
|
||||
));
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
@@ -281,9 +283,11 @@ fn signed_in(
|
||||
.strong(),
|
||||
);
|
||||
let status = match (&panel.sync_error, &panel.synced) {
|
||||
(Some(error), _) => format!("Watch history did not sync: {error}"),
|
||||
(Some(error), _) => format!("Did not sync: {error}"),
|
||||
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
|
||||
(None, None) => "Watch history syncs with your Drive".to_owned(),
|
||||
(None, None) => {
|
||||
"Library, settings and watch history sync through your Drive".to_owned()
|
||||
}
|
||||
};
|
||||
ui.label(ui::muted(status));
|
||||
});
|
||||
@@ -312,13 +316,7 @@ fn signed_in(
|
||||
|
||||
/// "Synced 3 min ago · 2 positions from other devices".
|
||||
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
|
||||
let ago = (now - at).max(0);
|
||||
let when = match ago {
|
||||
0..60 => "just now".to_owned(),
|
||||
60..3600 => format!("{} min ago", ago / 60),
|
||||
_ => format!("{} h ago", ago / 3600),
|
||||
};
|
||||
let mut line = format!("Synced {when}");
|
||||
let mut line = format!("Synced {}", ui::ago(at, now));
|
||||
if report.applied > 0 {
|
||||
line.push_str(&format!(
|
||||
" · {} from other devices",
|
||||
@@ -382,7 +380,7 @@ pub fn browser(
|
||||
if !browser.trail.is_empty()
|
||||
&& ui
|
||||
.button(egui_phosphor::regular::ARROW_LEFT)
|
||||
.on_hover_text("Up a folder")
|
||||
.hint("Up a folder")
|
||||
.clicked()
|
||||
{
|
||||
browser.up(actions);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Inspectable desktop download queue.
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadState};
|
||||
use crate::{theme, ui};
|
||||
|
||||
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
}
|
||||
}
|
||||
DownloadState::Completed => {
|
||||
// The point of a download is to watch it, and the library is a
|
||||
// detour from here — offline most of all.
|
||||
if ui::accent_button(ui, "Play").clicked() {
|
||||
actions.push(Action::PlayResuming(item.target.clone()));
|
||||
}
|
||||
if ui.button("Open").on_hover_text("Go to its title").clicked() {
|
||||
actions.push(Action::Goto(Page::Title(item.target.title_key.clone())));
|
||||
}
|
||||
if ui
|
||||
.button("Delete")
|
||||
.on_hover_text("Delete the offline copy; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key(), false));
|
||||
actions.push(Action::DeleteOffline(vec![key()], item.target.caption()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||
|
||||
use crate::app::{Action, Page};
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, Art};
|
||||
|
||||
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||
@@ -161,7 +162,7 @@ fn row(
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.on_hover_text(if episode.is_watched() {
|
||||
.hint(if episode.is_watched() {
|
||||
"Remove from history — marks it unwatched"
|
||||
} else {
|
||||
"Remove from history — forgets where you stopped"
|
||||
|
||||
@@ -19,6 +19,11 @@ pub struct Shelves<'a> {
|
||||
pub search: &'a str,
|
||||
/// The grid's shape.
|
||||
pub tiles: Tiles,
|
||||
/// What a crawl in progress has found, if one is.
|
||||
pub crawl: Option<&'a str>,
|
||||
/// Whether to offer posters and synopses: they are off, and the offer has
|
||||
/// not been turned down.
|
||||
pub offer_metadata: bool,
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
|
||||
@@ -28,12 +33,14 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
||||
loaded,
|
||||
search,
|
||||
tiles,
|
||||
crawl,
|
||||
offer_metadata,
|
||||
} = shelves;
|
||||
if !loaded {
|
||||
return placeholder(ui);
|
||||
}
|
||||
if library.is_empty() {
|
||||
return empty_state(ui, actions);
|
||||
return empty_state(ui, crawl, actions);
|
||||
}
|
||||
|
||||
let pick = |indices: &[usize]| -> Vec<&Title> {
|
||||
@@ -48,6 +55,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if !searching && offer_metadata {
|
||||
metadata_offer(ui, actions);
|
||||
ui.add_space(theme::space::XL);
|
||||
}
|
||||
if !searching {
|
||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||
hero(ui, art, title, actions);
|
||||
@@ -304,8 +315,16 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
let mut grouped = view.grouped;
|
||||
if ui
|
||||
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
|
||||
let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK);
|
||||
toggle.widget_info(|| {
|
||||
egui::WidgetInfo::selected(
|
||||
egui::WidgetType::SelectableLabel,
|
||||
true,
|
||||
view.grouped,
|
||||
"Group franchises",
|
||||
)
|
||||
});
|
||||
if toggle
|
||||
.on_hover_text(if view.grouped {
|
||||
"One tile per franchise. Click to show every title"
|
||||
} else {
|
||||
@@ -316,17 +335,8 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
||||
actions.push(Action::SetGrouped(grouped));
|
||||
}
|
||||
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"),
|
||||
],
|
||||
) {
|
||||
let filters = Filter::ALL.map(|filter| (filter, filter.label()));
|
||||
if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
|
||||
actions.push(Action::SetShelf(filter, view.sort));
|
||||
}
|
||||
}
|
||||
@@ -382,7 +392,7 @@ fn tile_shape(tiles: Tiles) -> (f32, f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The top shelf: one card per part-watched title, scrolling sideways.
|
||||
/// The top shelf: one card per title in progress, scrolling sideways.
|
||||
fn continue_row(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
@@ -395,21 +405,17 @@ fn continue_row(
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for title in titles {
|
||||
let Some(episode) = title.resume() else {
|
||||
let Some(episode) = title.continue_with() else {
|
||||
continue;
|
||||
};
|
||||
let remaining = episode
|
||||
.watch
|
||||
.and_then(|watch| watch.duration_secs)
|
||||
.map(|duration| {
|
||||
format!(
|
||||
"{} left",
|
||||
ui::format_time(
|
||||
duration - episode.watch.map_or(0.0, |w| w.position_secs)
|
||||
)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| episode.label());
|
||||
let remaining = match episode.resume_at() {
|
||||
Some(at) => episode
|
||||
.watch
|
||||
.and_then(|watch| watch.duration_secs)
|
||||
.map(|duration| format!("{} left", ui::format_time(duration - at)))
|
||||
.unwrap_or_else(|| episode.label()),
|
||||
None => format!("Next: {}", episode.label()),
|
||||
};
|
||||
|
||||
let picture = art.of(title);
|
||||
let response = ui::card(
|
||||
@@ -550,7 +556,7 @@ fn tile_menu(
|
||||
});
|
||||
}
|
||||
if continuing
|
||||
&& let Some(episode) = title.resume()
|
||||
&& let Some(episode) = title.continue_with()
|
||||
&& ui
|
||||
.button("Remove from Continue watching")
|
||||
.on_hover_text("Forget where you stopped in it")
|
||||
@@ -639,9 +645,54 @@ fn placeholder(ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
/// Posters and synopses, offered once there is a library to put them on and
|
||||
/// never turned on unasked: switching them on sends title names out.
|
||||
fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||
.inner_margin(egui::Margin::symmetric(14, 12))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Add posters and synopses?").strong());
|
||||
ui.label(ui::muted(
|
||||
"Turning it on in Settings sends the titles in your library — not your \
|
||||
files, and nothing about what you have watched — to AniList or TMDB.",
|
||||
));
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Not now").clicked() {
|
||||
actions.push(Action::DismissMetadataOffer);
|
||||
}
|
||||
if ui::accent_button(ui, "Open settings").clicked() {
|
||||
actions.push(Action::Goto(Page::Settings));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec<Action>) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(120.0);
|
||||
// A share just added: say it is being looked through, rather than
|
||||
// offer to add the one that is already filling the page.
|
||||
if let Some(crawl) = crawl {
|
||||
ui.add(egui::Spinner::new().size(28.0));
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(
|
||||
theme::Role::Title
|
||||
.rich("Looking through the share")
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
ui.label(ui::muted(format!(
|
||||
"Its titles appear here once it has been listed ({crawl})."
|
||||
)));
|
||||
return;
|
||||
}
|
||||
ui.label(
|
||||
theme::Role::Title
|
||||
.rich("Nothing in the library yet")
|
||||
@@ -653,7 +704,7 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
));
|
||||
ui.add_space(theme::space::XL);
|
||||
if ui::accent_button(ui, "Add a share").clicked() {
|
||||
actions.push(Action::Goto(Page::Shares));
|
||||
actions.push(Action::OpenShareForm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,12 +130,29 @@ fn current_row(
|
||||
)));
|
||||
// Only worth offering when there is something to undo: with no
|
||||
// row stored, "clear" would delete nothing.
|
||||
if ui
|
||||
//
|
||||
// A match chosen by hand is asked about first: it is work the
|
||||
// next match run does not redo.
|
||||
let asking = ui.id().with(("forget", &matcher.title_key));
|
||||
if ui.data(|data| data.get_temp::<bool>(asking).unwrap_or(false)) {
|
||||
ui.label(ui::muted("Forget the match you chose?"));
|
||||
if ui::danger_button(ui, "Forget").clicked() {
|
||||
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
}
|
||||
if ui.button("Keep").clicked() {
|
||||
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||
}
|
||||
} else if ui
|
||||
.button("Clear")
|
||||
.on_hover_text("Forget this match and let the next match run decide again")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
if *manual {
|
||||
ui.data_mut(|data| data.insert_temp(asking, true));
|
||||
} else {
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((None, _)) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecor
|
||||
|
||||
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
||||
use crate::theme;
|
||||
use widgets::Named;
|
||||
|
||||
/// Everything the pages need to draw one title's picture.
|
||||
///
|
||||
@@ -172,6 +173,19 @@ pub fn wide_key(title_key: &str) -> String {
|
||||
format!("wide:{title_key}")
|
||||
}
|
||||
|
||||
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
|
||||
/// the Unix time `at` was.
|
||||
pub fn ago(at: i64, now: i64) -> String {
|
||||
let ago = (now - at).max(0);
|
||||
match ago {
|
||||
0..60 => "just now".to_owned(),
|
||||
60..3600 => format!("{} min ago", ago / 60),
|
||||
3600..86_400 => format!("{} h ago", ago / 3600),
|
||||
86_400..172_800 => "1 day ago".to_owned(),
|
||||
_ => format!("{} days ago", ago / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
/// `1:03:47`, or `4:12` for anything under an hour.
|
||||
pub fn format_time(seconds: f64) -> String {
|
||||
if !seconds.is_finite() || seconds < 0.0 {
|
||||
@@ -311,7 +325,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
);
|
||||
if response
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("Clear (Esc)")
|
||||
.hint("Clear (Esc)")
|
||||
.clicked()
|
||||
{
|
||||
search.clear();
|
||||
@@ -374,6 +388,19 @@ pub enum Answer {
|
||||
/// 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 {
|
||||
confirm_with(ctx, id, heading, body, verb, true)
|
||||
}
|
||||
|
||||
/// [`confirm`], for a question whose yes loses nothing — a large download —
|
||||
/// and so is the accent rather than the danger colour.
|
||||
pub fn confirm_with(
|
||||
ctx: &egui::Context,
|
||||
id: &str,
|
||||
heading: &str,
|
||||
body: &str,
|
||||
verb: &str,
|
||||
destroys: bool,
|
||||
) -> Answer {
|
||||
let mut answer = Answer::Pending;
|
||||
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||
.frame(
|
||||
@@ -394,7 +421,12 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
|
||||
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() {
|
||||
let yes = if destroys {
|
||||
danger_button(ui, verb)
|
||||
} else {
|
||||
accent_button(ui, verb)
|
||||
};
|
||||
if yes.clicked() {
|
||||
answer = Answer::Confirmed;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
@@ -498,6 +530,7 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
||||
);
|
||||
|
||||
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||
let response = response.named(text);
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return response;
|
||||
}
|
||||
@@ -595,6 +628,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||
let response = if card.subtitle.is_empty() {
|
||||
response.named(card.name)
|
||||
} else {
|
||||
response.named(format!("{}, {}", card.name, card.subtitle))
|
||||
};
|
||||
// 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() {
|
||||
|
||||
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
|
||||
use crate::app::{Action, Adjacent};
|
||||
use crate::playback::{Command, Playback};
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, transport::Neighbours};
|
||||
|
||||
/// How long the controls stay up after the pointer stops moving.
|
||||
@@ -139,6 +140,9 @@ pub struct UpNextCard {
|
||||
pub caption: String,
|
||||
/// Its still, once there is one.
|
||||
pub still: Option<egui::TextureHandle>,
|
||||
/// Counting down the end of a file with no credits chapter, where the
|
||||
/// second button cancels the next episode rather than deferring it.
|
||||
pub tail: bool,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
@@ -185,16 +189,28 @@ impl Overlay {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player page shows while a file opens and there is no picture yet.
|
||||
pub struct Waiting<'a> {
|
||||
/// The episode being opened.
|
||||
pub name: &'a str,
|
||||
/// The title it belongs to, as the library names it.
|
||||
pub title: Option<String>,
|
||||
/// The title's widest art, dimmed behind the words.
|
||||
pub art: Option<egui::TextureHandle>,
|
||||
/// Whether one episode is following another, rather than a first click.
|
||||
pub next: bool,
|
||||
}
|
||||
|
||||
/// Draw the picture and its controls.
|
||||
///
|
||||
/// `playback` is `None` in the gap between one episode ending and the next
|
||||
/// having opened — the page stays, because that is where the viewer means to
|
||||
/// be, and `opening` is what it says while they wait.
|
||||
/// `playback` is `None` until the file has opened — on a first click, and in
|
||||
/// the gap between one episode ending and the next — and `opening` is what the
|
||||
/// page says meanwhile.
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
frame: &mut eframe::Frame,
|
||||
playback: Option<&mut Playback>,
|
||||
opening: Option<&str>,
|
||||
opening: Option<Waiting<'_>>,
|
||||
chrome_state: Chrome<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -372,26 +388,60 @@ fn stalled(ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The screen between two episodes: one has ended and the next is opening.
|
||||
/// The screen while a file opens: after a click, or between one episode and
|
||||
/// the next.
|
||||
///
|
||||
/// It keeps a way out, because opening a file over a public link can take
|
||||
/// seconds and a black screen with no controls is indistinguishable from a
|
||||
/// hang.
|
||||
fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut Vec<Action>) {
|
||||
fn between(ui: &mut egui::Ui, waiting: Option<Waiting<'_>>, rect: Rect, actions: &mut Vec<Action>) {
|
||||
let Waiting {
|
||||
name,
|
||||
title,
|
||||
art,
|
||||
next,
|
||||
} = waiting.unwrap_or(Waiting {
|
||||
name: "opening…",
|
||||
title: None,
|
||||
art: None,
|
||||
next: true,
|
||||
});
|
||||
if let Some(art) = &art {
|
||||
// Cover rather than fit: bars around a dimmed backdrop look like a
|
||||
// mistake, and its edges are not what anyone is looking at.
|
||||
let size = art.size_vec2();
|
||||
let scale = (rect.width() / size.x).max(rect.height() / size.y);
|
||||
let shown = size * scale;
|
||||
let uv = Rect::from_center_size(
|
||||
egui::pos2(0.5, 0.5),
|
||||
Vec2::new(rect.width() / shown.x, rect.height() / shown.y),
|
||||
);
|
||||
ui.painter()
|
||||
.image(art.id(), rect, uv, Color32::from_white_alpha(70));
|
||||
}
|
||||
ui.painter().text(
|
||||
rect.center() - Vec2::new(0.0, 16.0),
|
||||
Align2::CENTER_CENTER,
|
||||
"Up next",
|
||||
match (&title, next) {
|
||||
(_, true) => "Up next",
|
||||
(Some(title), false) => title.as_str(),
|
||||
(None, false) => "Opening",
|
||||
},
|
||||
theme::Role::Body.font(),
|
||||
theme::muted(),
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.center() + Vec2::new(0.0, 12.0),
|
||||
Align2::CENTER_CENTER,
|
||||
opening.unwrap_or("opening…"),
|
||||
name,
|
||||
theme::Role::Title.font(),
|
||||
theme::text(),
|
||||
);
|
||||
let spinner = Rect::from_center_size(rect.center() + Vec2::new(0.0, 56.0), Vec2::splat(24.0));
|
||||
ui.put(
|
||||
spinner,
|
||||
egui::Spinner::new().size(24.0).color(Color32::WHITE),
|
||||
);
|
||||
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new()
|
||||
@@ -404,8 +454,6 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
||||
if ui.button("Back").clicked() {
|
||||
actions.push(Action::LeavePlayer);
|
||||
}
|
||||
ui.add_space(theme::space::M);
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -539,7 +587,7 @@ fn up_next_card(
|
||||
theme::Role::Body.font(),
|
||||
Color32::WHITE,
|
||||
);
|
||||
if response.on_hover_text("Play now").clicked() {
|
||||
if response.hint("Play now").clicked() {
|
||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
}
|
||||
|
||||
@@ -566,11 +614,15 @@ fn up_next_card(
|
||||
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()
|
||||
{
|
||||
let (label, hint) = if card.tail {
|
||||
("Cancel", "Stay here — the next episode will not start")
|
||||
} else {
|
||||
(
|
||||
"Watch till the end",
|
||||
"Play this one out — nothing will be skipped",
|
||||
)
|
||||
};
|
||||
if ui.button(label).on_hover_text(hint).clicked() {
|
||||
actions.push(Action::WatchToEnd);
|
||||
}
|
||||
});
|
||||
@@ -695,7 +747,7 @@ fn chrome(
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.on_hover_text(if fullscreen {
|
||||
.hint(if fullscreen {
|
||||
"Leave fullscreen (F)"
|
||||
} else {
|
||||
"Fullscreen (F)"
|
||||
@@ -727,12 +779,31 @@ fn chrome(
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.on_hover_text("Episodes (E)")
|
||||
.hint("Episodes (E)")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleEpisodes);
|
||||
}
|
||||
}
|
||||
// Esc leaves the page and keeps playing; this is the one-step
|
||||
// way to be done with the episode.
|
||||
ui.add_space(theme::space::S);
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
theme::Role::Subhead
|
||||
.rich(egui_phosphor::regular::STOP)
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.hint("Stop")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Player(Command::Stop));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -834,7 +905,7 @@ fn episode_drawer(
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.on_hover_text("Close (E)")
|
||||
.hint("Close (E)")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleEpisodes);
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_player::LANGUAGES;
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs, TEXT_SIZE_CHOICES};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// The settings that are a single value rather than a form.
|
||||
///
|
||||
@@ -19,9 +22,11 @@ use crate::ui;
|
||||
/// 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 struct Prefs<'a> {
|
||||
pub autoplay: bool,
|
||||
pub auto_skip: bool,
|
||||
/// The languages, read from here.
|
||||
pub playback: &'a PlaybackPrefs,
|
||||
pub appearance: Appearance,
|
||||
pub desktop: DesktopPrefs,
|
||||
/// What the streaming cache holds, once the shares are open.
|
||||
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
prefs: Prefs,
|
||||
prefs: Prefs<'_>,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -51,7 +56,7 @@ pub fn show(
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width);
|
||||
ui.add_space(theme::space::M);
|
||||
appearance_form(ui, prefs.appearance, actions);
|
||||
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
||||
playback_form(ui, prefs, actions);
|
||||
storage_form(ui, prefs, actions);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
@@ -66,7 +71,12 @@ pub fn show(
|
||||
/// 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>) {
|
||||
fn appearance_form(
|
||||
ui: &mut egui::Ui,
|
||||
appearance: Appearance,
|
||||
desktop: DesktopPrefs,
|
||||
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()));
|
||||
@@ -75,6 +85,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for flavor in Flavor::ALL {
|
||||
if palette_card(ui, appearance, flavor)
|
||||
.named(flavor.label())
|
||||
.on_hover_text(flavor.description())
|
||||
.clicked()
|
||||
&& appearance.flavor != flavor
|
||||
@@ -102,6 +113,27 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
}
|
||||
});
|
||||
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Text size",
|
||||
Some("Everything grows with it. Kept on this computer only."),
|
||||
|ui| {
|
||||
let choices: Vec<(u16, String)> = TEXT_SIZE_CHOICES
|
||||
.iter()
|
||||
.map(|&percent| (percent, format!("{percent}%")))
|
||||
.collect();
|
||||
let labels: Vec<(u16, &str)> = choices
|
||||
.iter()
|
||||
.map(|(percent, label)| (*percent, label.as_str()))
|
||||
.collect();
|
||||
if let Some(text_size) = ui::widgets::segmented(ui, desktop.text_size, &labels) {
|
||||
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
|
||||
text_size,
|
||||
..desktop
|
||||
}));
|
||||
}
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Library tiles",
|
||||
@@ -265,22 +297,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
||||
}
|
||||
}
|
||||
response
|
||||
.on_hover_text(accent.label())
|
||||
.hint(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>) {
|
||||
/// Volume and speed are set from the player itself, where a viewer can hear
|
||||
/// the result. The languages are here as well as there: what a first episode
|
||||
/// opens with is decided before there is a player to choose from.
|
||||
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.",
|
||||
"Volume and speed are set from the player. A language chosen in the player for one show is kept for that show.",
|
||||
),
|
||||
|ui| {
|
||||
languages(ui, prefs.playback, actions);
|
||||
let mut autoplay = prefs.autoplay;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
@@ -322,8 +356,80 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The audio and subtitle languages every file opens with, until a show has a
|
||||
/// choice of its own.
|
||||
fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Action>) {
|
||||
let name = |tag: &Option<String>| match tag {
|
||||
Some(tag) => pstr_player::language_name(tag),
|
||||
None => "The file's default".to_owned(),
|
||||
};
|
||||
let audio = playback.audio_language.clone();
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Audio language",
|
||||
Some("Played when a file has it."),
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("audio-language")
|
||||
.selected_text(name(&audio))
|
||||
.show_ui(ui, |ui| {
|
||||
for choice in
|
||||
std::iter::once(None).chain(LANGUAGES.map(|tag| Some(tag.to_owned())))
|
||||
{
|
||||
if ui
|
||||
.selectable_label(audio == choice, name(&choice))
|
||||
.clicked()
|
||||
&& audio != choice
|
||||
{
|
||||
actions.push(Action::SetLanguages {
|
||||
audio: choice,
|
||||
subtitles: playback.subtitles,
|
||||
subtitle: playback.subtitle_language.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
// Off, the file's default, or a language: "off" is a decision of its
|
||||
// own, which no language expresses.
|
||||
let subtitle = playback
|
||||
.subtitles
|
||||
.then(|| playback.subtitle_language.clone());
|
||||
let subtitle_name = |choice: &Option<Option<String>>| match choice {
|
||||
None => "Off".to_owned(),
|
||||
Some(tag) => name(tag),
|
||||
};
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Subtitles",
|
||||
Some("Shown when a file has them in this language."),
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("subtitle-language")
|
||||
.selected_text(subtitle_name(&subtitle))
|
||||
.show_ui(ui, |ui| {
|
||||
let choices = [None, Some(None)]
|
||||
.into_iter()
|
||||
.chain(LANGUAGES.map(|tag| Some(Some(tag.to_owned()))));
|
||||
for choice in choices {
|
||||
if ui
|
||||
.selectable_label(subtitle == choice, subtitle_name(&choice))
|
||||
.clicked()
|
||||
&& subtitle != choice
|
||||
{
|
||||
actions.push(Action::SetLanguages {
|
||||
audio: playback.audio_language.clone(),
|
||||
subtitles: choice.is_some(),
|
||||
subtitle: choice.flatten(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Storage",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
//! below is the only place a link is ever visible, and only while it is being
|
||||
//! typed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pstr_core::Share;
|
||||
use pstr_core::catalog::CrawlRecord;
|
||||
use pstr_core::library::Library;
|
||||
|
||||
use crate::app::{Action, ShareForm};
|
||||
@@ -16,6 +19,7 @@ use crate::ui;
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
shares: &[Share],
|
||||
crawls: &HashMap<String, CrawlRecord>,
|
||||
library: &Library,
|
||||
form: &mut ShareForm,
|
||||
account: &mut ui::account::AccountPanel,
|
||||
@@ -36,7 +40,7 @@ pub fn show(
|
||||
.iter()
|
||||
.filter(|title| title.share_ids.contains(&share.id))
|
||||
.count();
|
||||
share_row(ui, share, titles, actions);
|
||||
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
|
||||
}
|
||||
|
||||
ui.add_space(theme::space::XXL);
|
||||
@@ -48,7 +52,14 @@ pub fn show(
|
||||
});
|
||||
}
|
||||
|
||||
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
||||
fn share_row(
|
||||
ui: &mut egui::Ui,
|
||||
share: &Share,
|
||||
titles: usize,
|
||||
crawl: Option<&CrawlRecord>,
|
||||
form: &mut ShareForm,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||
@@ -67,7 +78,23 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
||||
if share.has_custom_password {
|
||||
detail.push_str(" · password protected");
|
||||
}
|
||||
match crawl {
|
||||
Some(crawl) if crawl.crawled_at > 0 => {
|
||||
detail.push_str(" · crawled ");
|
||||
detail.push_str(&ui::ago(crawl.crawled_at, crate::engine::unix_now()));
|
||||
}
|
||||
_ => detail.push_str(" · not crawled yet"),
|
||||
}
|
||||
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||
// Why the last look failed, until one succeeds: a link
|
||||
// revoked upstream otherwise just stops growing.
|
||||
if let Some(error) = crawl.and_then(|crawl| crawl.error.as_deref()) {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(format!("Last crawl failed: {error}"))
|
||||
.color(theme::danger()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -81,12 +108,121 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
||||
if ui.button("Crawl").clicked() {
|
||||
actions.push(Action::Crawl(Some(share.id.clone())));
|
||||
}
|
||||
if ui.button("Rename").clicked() {
|
||||
form.renaming = Some(share.id.clone());
|
||||
form.rename_to = share.name.clone();
|
||||
}
|
||||
// Not for a folder of the account, which has no link.
|
||||
if share.folder.is_none()
|
||||
&& ui
|
||||
.button("Re-enter link")
|
||||
.on_hover_text(
|
||||
"For a keyring that lost the link, or a password that changed. \
|
||||
The library, history and downloads stay.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
form.repairing = Some(share.id.clone());
|
||||
form.repair_url.clear();
|
||||
form.repair_password.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
if form.repairing.as_deref() == Some(share.id.as_str()) {
|
||||
repair_form(ui, share, form, actions);
|
||||
}
|
||||
if form.renaming.as_deref() == Some(share.id.as_str()) {
|
||||
rename_form(ui, share, form, actions);
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
|
||||
/// A new name for a share, in its own row. Enter saves, as everywhere else.
|
||||
fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.horizontal(|ui| {
|
||||
let field = ui.add(
|
||||
egui::TextEdit::singleline(&mut form.rename_to)
|
||||
.hint_text("Name")
|
||||
.desired_width(320.0),
|
||||
);
|
||||
let ready = !form.rename_to.trim().is_empty();
|
||||
let entered = field.lost_focus() && ui_enter_pressed(ui.ctx());
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||
.inner
|
||||
.clicked()
|
||||
|| (ready && entered)
|
||||
{
|
||||
actions.push(Action::RenameShare {
|
||||
id: share.id.clone(),
|
||||
name: form.rename_to.trim().to_owned(),
|
||||
});
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
form.renaming = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The same link again, and a password if it has one, for a share already in
|
||||
/// the library.
|
||||
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.repair_url)
|
||||
.password(true)
|
||||
.hint_text("The share link, including everything after the #")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.repair_password)
|
||||
.password(true)
|
||||
.hint_text("Link password, if it has one")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
let problem = (!form.repair_url.trim().is_empty())
|
||||
.then(|| pstr_core::shares::share_token(form.repair_url.trim()).err())
|
||||
.flatten();
|
||||
if let Some(problem) = &problem {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(problem.to_string())
|
||||
.color(theme::danger()),
|
||||
);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let ready = !form.repair_url.trim().is_empty() && problem.is_none();
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RepairShare {
|
||||
id: share.id.clone(),
|
||||
url: form.repair_url.trim().to_owned(),
|
||||
password: Some(form.repair_password.clone())
|
||||
.filter(|password| !password.is_empty()),
|
||||
});
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
form.repairing = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The add form's link field, which the library's empty state focuses.
|
||||
pub fn link_field_id() -> egui::Id {
|
||||
egui::Id::new("share-link")
|
||||
}
|
||||
|
||||
fn ui_enter_pressed(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|input| input.key_pressed(egui::Key::Enter))
|
||||
}
|
||||
|
||||
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Add a share");
|
||||
ui.label(ui::muted(
|
||||
@@ -102,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted("Name"));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.name)
|
||||
.hint_text("Anime")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::M);
|
||||
// Enter in any field of the form submits it, as the sign-in
|
||||
// form's do.
|
||||
let mut entered = false;
|
||||
let mut submitted_from = |response: egui::Response| {
|
||||
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
|
||||
};
|
||||
|
||||
ui.label(ui::muted("Link"));
|
||||
ui.horizontal(|ui| {
|
||||
let paste = 60.0;
|
||||
let field = egui::Id::new("share-link");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(ui.available_width() - paste),
|
||||
let field = link_field_id();
|
||||
if std::mem::take(&mut form.focus) {
|
||||
ui.memory_mut(|memory| memory.request_focus(field));
|
||||
}
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(ui.available_width() - paste),
|
||||
),
|
||||
);
|
||||
// The link is masked, so pasting is the only sensible way in —
|
||||
// and a button says that more plainly than the field does.
|
||||
@@ -144,20 +284,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
}
|
||||
ui.add_space(theme::space::M);
|
||||
|
||||
ui.label(ui::muted("Name (optional)"));
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.name)
|
||||
.hint_text("The shared folder's own name")
|
||||
.desired_width(f32::INFINITY),
|
||||
),
|
||||
);
|
||||
ui.add_space(theme::space::M);
|
||||
|
||||
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||
if form.has_password {
|
||||
ui.add_space(theme::space::S);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.password)
|
||||
.password(true)
|
||||
.hint_text("Link password")
|
||||
.desired_width(f32::INFINITY),
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.password)
|
||||
.password(true)
|
||||
.hint_text("Link password")
|
||||
.desired_width(f32::INFINITY),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(theme::space::L);
|
||||
let ready = !form.name.trim().is_empty()
|
||||
&& !form.url.trim().is_empty()
|
||||
let ready = !form.url.trim().is_empty()
|
||||
&& problem.is_none()
|
||||
&& (!form.has_password || !form.password.is_empty())
|
||||
&& !form.sending;
|
||||
@@ -167,11 +318,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
||||
let clicked = ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
.clicked();
|
||||
if clicked || (ready && entered) {
|
||||
actions.push(Action::AddShare {
|
||||
name: form.name.trim().to_string(),
|
||||
url: form.url.trim().to_string(),
|
||||
@@ -184,8 +335,8 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
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."));
|
||||
} else if form.url.trim().is_empty() {
|
||||
ui.label(ui::muted("Paste a share link to add it."));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||
use crate::playback::PlaybackTarget;
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, Art, Card};
|
||||
|
||||
pub struct OfflineView<'a> {
|
||||
@@ -96,6 +97,7 @@ pub fn show(
|
||||
)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
season_download(ui, title, season, &offline, actions);
|
||||
season_watched(ui, title, season, actions);
|
||||
});
|
||||
});
|
||||
ui.add_space(theme::space::L);
|
||||
@@ -207,6 +209,29 @@ fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||
}
|
||||
|
||||
/// Download, or delete, a whole season.
|
||||
/// Mark a whole season seen, or unseen once it all is — for someone who
|
||||
/// watched it elsewhere and joins the show here.
|
||||
fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &mut Vec<Action>) {
|
||||
let all = season.episodes.iter().all(|episode| episode.is_watched());
|
||||
let label = if all {
|
||||
"Mark season unwatched"
|
||||
} else {
|
||||
"Mark season watched"
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links: season
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||
.collect(),
|
||||
watched: !all,
|
||||
what: format!("{} {}", title.name, season.label()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn season_download(
|
||||
ui: &mut egui::Ui,
|
||||
title: &Title,
|
||||
@@ -222,9 +247,10 @@ fn season_download(
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in season_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
actions.push(Action::DeleteOffline(
|
||||
season_keys,
|
||||
format!("{} {}", title.name, season.label()),
|
||||
));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download season")
|
||||
@@ -365,9 +391,7 @@ fn header(
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in title_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download show")
|
||||
@@ -760,14 +784,33 @@ fn episode_row(
|
||||
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||
if ui::widgets::watched_mark(ui, watched)
|
||||
.on_hover_text(if watched {
|
||||
"Mark unwatched"
|
||||
} else {
|
||||
"Mark watched"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
|
||||
"Mark unwatched. Right-click for more."
|
||||
} else {
|
||||
"Mark watched. Right-click for more."
|
||||
});
|
||||
// Joining a show partway is the usual reason to mark
|
||||
// anything, and one tick per earlier episode is a chore.
|
||||
mark.context_menu(|ui| {
|
||||
if ui.button("Mark watched up to here").clicked() {
|
||||
let links = title
|
||||
.episodes()
|
||||
.take(
|
||||
title
|
||||
.position_of(&episode.node.share_id, &episode.node.link_id)
|
||||
.map_or(0, |at| at + 1),
|
||||
)
|
||||
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||
.collect();
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links,
|
||||
watched: true,
|
||||
what: format!("{} up to {}", title.name, episode.label()),
|
||||
});
|
||||
}
|
||||
});
|
||||
if mark.clicked() {
|
||||
actions.push(Action::SetWatched {
|
||||
share_id: episode.node.share_id.clone(),
|
||||
link_id: episode.node.link_id.clone(),
|
||||
@@ -836,6 +879,7 @@ pub(crate) fn still(
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||
let response = response.named(format!("Play {}", episode.label()));
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return;
|
||||
}
|
||||
@@ -941,7 +985,7 @@ fn download(
|
||||
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
actions.push(Action::DeleteOffline(vec![key], episode.label()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// How long a message stays up.
|
||||
const SECONDS: f64 = 5.0;
|
||||
@@ -52,6 +53,17 @@ impl Toasts {
|
||||
self.push_inner(now, text.into(), false, Some((label, action)));
|
||||
}
|
||||
|
||||
/// An error with a button on it — "Retry".
|
||||
pub fn push_error_with(
|
||||
&mut self,
|
||||
now: f64,
|
||||
text: impl Into<String>,
|
||||
label: &'static str,
|
||||
action: Action,
|
||||
) {
|
||||
self.push_inner(now, text.into(), true, Some((label, action)));
|
||||
}
|
||||
|
||||
fn push_inner(
|
||||
&mut self,
|
||||
now: f64,
|
||||
@@ -203,7 +215,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
||||
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||
Stroke::new(1.5, ink),
|
||||
);
|
||||
if response.on_hover_text("Dismiss").clicked() {
|
||||
if response.hint("Dismiss").clicked() {
|
||||
pressed = Pressed::Close;
|
||||
}
|
||||
if let Some((label, _)) = &toast.action {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::app::{Action, Adjacent, Page};
|
||||
use crate::playback::{Command, Playback};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
||||
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
|
||||
@@ -163,7 +164,7 @@ pub fn mini(
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
if response.on_hover_text("Show the picture again").clicked() {
|
||||
if response.hint("Show the picture again").clicked() {
|
||||
actions.push(Action::Goto(Page::Player));
|
||||
}
|
||||
}
|
||||
@@ -313,7 +314,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
|
||||
}
|
||||
|
||||
if response
|
||||
.on_hover_text(if playback.paused {
|
||||
.hint(if playback.paused {
|
||||
"Play (Space)"
|
||||
} else {
|
||||
"Pause (Space)"
|
||||
@@ -354,7 +355,7 @@ fn step_button(
|
||||
enabled,
|
||||
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.hint(hover).clicked() {
|
||||
push(actions);
|
||||
}
|
||||
}
|
||||
@@ -436,7 +437,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
};
|
||||
if ui
|
||||
.button(theme::Role::Subhead.rich(icon))
|
||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
||||
.hint(format!("Mute (M) — {:.0}%", playback.volume))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleMute);
|
||||
@@ -509,7 +510,8 @@ fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>)
|
||||
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] {
|
||||
// The same steps `[` and `]` walk.
|
||||
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
|
||||
if ui
|
||||
.selectable_label(
|
||||
(playback.speed - speed).abs() < 1e-6,
|
||||
@@ -566,6 +568,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
||||
let width = ui.available_width();
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::slider(
|
||||
playback.duration.is_some(),
|
||||
playback.position,
|
||||
format!("Position, {}", ui::format_time(playback.position)),
|
||||
)
|
||||
});
|
||||
|
||||
let active = response.hovered() || response.dragged();
|
||||
// Grows rather than jumps when the pointer arrives.
|
||||
|
||||
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// A name for a screen reader, on a control whose face does not say it.
|
||||
///
|
||||
/// A button drawn as an icon glyph is announced as the icon font's private
|
||||
/// code point, and one painted by hand as nothing at all.
|
||||
pub trait Named {
|
||||
/// The control's name to a screen reader.
|
||||
fn named(self, name: impl Into<String>) -> Self;
|
||||
/// Hover text that is also the control's name.
|
||||
fn hint(self, text: impl Into<String>) -> Self;
|
||||
}
|
||||
|
||||
impl Named for egui::Response {
|
||||
fn named(self, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
let enabled = self.enabled();
|
||||
self.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||
self
|
||||
}
|
||||
|
||||
fn hint(self, text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
self.named(text.clone()).on_hover_text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// An on/off switch with its label beside it, for settings.
|
||||
///
|
||||
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
||||
@@ -98,6 +123,9 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
||||
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());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, watched, "Watched")
|
||||
});
|
||||
if ui.is_rect_visible(rect) {
|
||||
let ctx = ui.ctx();
|
||||
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||
@@ -278,6 +306,12 @@ pub enum DownloadGlyph {
|
||||
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());
|
||||
let response = response.named(match glyph {
|
||||
DownloadGlyph::Download => "Download".to_owned(),
|
||||
DownloadGlyph::Progress(done) => format!("Downloading, {:.0}%", done * 100.0),
|
||||
DownloadGlyph::Paused(done) => format!("Download paused, {:.0}%", done * 100.0),
|
||||
DownloadGlyph::Done => "Downloaded".to_owned(),
|
||||
});
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let share = store
|
||||
.add(&name, &url, password.as_deref())
|
||||
let (share, _) = store
|
||||
.add_verified(&name, &url, password.as_deref())
|
||||
.await
|
||||
.context("add share")?;
|
||||
println!("added {} ({})", share.name, share.id);
|
||||
}
|
||||
@@ -323,11 +324,29 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
let sync = WatchSync::open(&dirs).context("open sync state")?;
|
||||
let catalog =
|
||||
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
|
||||
let report = sync.sync(&account, &catalog).await.context("sync")?;
|
||||
let report = sync
|
||||
.sync(&account, &catalog, &store)
|
||||
.await
|
||||
.context("sync")?;
|
||||
let profile = &report.profile;
|
||||
for id in &profile.shares_removed {
|
||||
catalog
|
||||
.lock()
|
||||
.remove_share(id)
|
||||
.context("drop a removed share")?;
|
||||
}
|
||||
println!(
|
||||
"read {} other device(s), took {} newer position(s), {}",
|
||||
"read {} other device(s), took {} newer position(s), \
|
||||
{} share(s) added, {} removed, settings {}, {}",
|
||||
report.devices,
|
||||
report.applied,
|
||||
profile.shares_added.len() + profile.shares_changed.len(),
|
||||
profile.shares_removed.len(),
|
||||
if profile.settings {
|
||||
"updated"
|
||||
} else {
|
||||
"unchanged"
|
||||
},
|
||||
if report.uploaded {
|
||||
"uploaded this device's history"
|
||||
} else {
|
||||
@@ -358,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
|
||||
Command::Remove { id } => {
|
||||
store.remove(&id).context("remove share")?;
|
||||
// Its catalog rows go too, or the library keeps listing its files —
|
||||
// beside the new share's, when the same folder was added back
|
||||
// another way.
|
||||
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||
let offline = catalog
|
||||
.offline_files_for_share(&id)
|
||||
.context("read the share's downloads")?;
|
||||
for (link_id, file) in &offline {
|
||||
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
|
||||
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
|
||||
return Err(error).context("delete a downloaded file");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
catalog
|
||||
.remove_share(&id)
|
||||
.context("drop the share's catalog rows")?;
|
||||
println!("removed {id}");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::library::{Library, Title, sort_name};
|
||||
use crate::catalog::CatalogNode;
|
||||
use crate::library::{Library, Title, TitleKind, sort_name};
|
||||
use crate::metadata::{MetadataRecord, TitleNames};
|
||||
|
||||
/// What order a grid is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sort {
|
||||
/// By the name shown, ignoring a leading article.
|
||||
#[default]
|
||||
@@ -50,6 +52,60 @@ impl Sort {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which titles a grid shows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Filter {
|
||||
#[default]
|
||||
All,
|
||||
Series,
|
||||
Films,
|
||||
/// Started and not finished.
|
||||
Watching,
|
||||
/// Not started.
|
||||
Unwatched,
|
||||
/// With at least one episode kept on this device.
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::All,
|
||||
Self::Series,
|
||||
Self::Films,
|
||||
Self::Watching,
|
||||
Self::Unwatched,
|
||||
Self::Downloaded,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "All",
|
||||
Self::Series => "Series",
|
||||
Self::Films => "Films",
|
||||
Self::Watching => "Watching",
|
||||
Self::Unwatched => "Not started",
|
||||
Self::Downloaded => "Downloaded",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the grid shows `title`. `offline` says whether a file is kept
|
||||
/// on this device, which only the front end knows.
|
||||
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
|
||||
let watched = title.watched_count();
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Series => title.kind == TitleKind::Series,
|
||||
Self::Films => title.kind == TitleKind::Film,
|
||||
Self::Watching => {
|
||||
title.resume().is_some() || (watched > 0 && watched < title.episode_count())
|
||||
}
|
||||
Self::Unwatched => watched == 0 && title.resume().is_none(),
|
||||
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name to show for `title`: the provider's, in the form the viewer
|
||||
/// picked, else the share's.
|
||||
pub fn display_name<'a>(
|
||||
@@ -392,4 +448,44 @@ mod tests {
|
||||
assert_eq!(found[0].key, "sousou no frieren");
|
||||
assert_eq!(search(&library, &metadata, "").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filters_split_the_library_by_kind_and_progress() {
|
||||
let film = title("f", "Film", TitleKind::Film);
|
||||
let series = title("s", "Series", TitleKind::Series);
|
||||
let none = |_: &CatalogNode| false;
|
||||
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
|
||||
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
|
||||
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
|
||||
// Nothing watched of either: not started, and not being watched.
|
||||
assert!(Filter::Unwatched.admits(&film, none));
|
||||
assert!(!Filter::Watching.admits(&film, none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_downloaded_filter_admits_a_title_with_any_episode_kept_offline() {
|
||||
let node = |link: &str, episode| CatalogNode {
|
||||
share_id: "s".into(),
|
||||
link_id: link.into(),
|
||||
volume_id: "v".into(),
|
||||
parent_link_id: None,
|
||||
name: format!("Show - S01E0{episode}.mkv"),
|
||||
is_folder: false,
|
||||
media_type: Some("video/x-matroska".into()),
|
||||
size: Some(1),
|
||||
active_revision_id: Some("r".into()),
|
||||
parsed: crate::naming::ParsedName {
|
||||
title: "Show".into(),
|
||||
season: Some(1),
|
||||
episode: Some(episode),
|
||||
year: None,
|
||||
episode_title: None,
|
||||
},
|
||||
};
|
||||
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
|
||||
let show = &library.titles[0];
|
||||
|
||||
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
|
||||
assert!(!Filter::Downloaded.admits(show, |_| false));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use crate::metadata::{
|
||||
use crate::naming::{self, ParsedName};
|
||||
|
||||
/// Bump when adding a migration.
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
pub const SCHEMA_VERSION: i64 = 11;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
CREATE TABLE nodes (
|
||||
@@ -207,6 +207,51 @@ INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
|
||||
UPDATE title_metadata SET enriched_at = NULL;
|
||||
"#;
|
||||
|
||||
/// What the last crawl of each share came to.
|
||||
///
|
||||
/// Per installation, like the rest of the catalog: when this device last
|
||||
/// looked at a share is nothing another device needs. `crawled_at` is the last
|
||||
/// crawl that finished, `attempted_at` the last that was tried, so a share that
|
||||
/// keeps failing is not retried on every launch. `added` is how many files the
|
||||
/// last finished crawl found that had never been seen before.
|
||||
const MIGRATION_V11: &str = r#"
|
||||
CREATE TABLE share_crawls (
|
||||
share_id TEXT PRIMARY KEY,
|
||||
crawled_at INTEGER NOT NULL,
|
||||
attempted_at INTEGER NOT NULL,
|
||||
files INTEGER NOT NULL,
|
||||
added INTEGER NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
/// How old a share's last crawl may be before an app looks again on its own.
|
||||
/// Long enough that a launch rarely costs a crawl; short enough that a new
|
||||
/// episode turns up the same day.
|
||||
pub const CRAWL_STALE_AFTER_SECS: i64 = 6 * 60 * 60;
|
||||
|
||||
/// One share's row of `share_crawls`. See [`MIGRATION_V11`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CrawlRecord {
|
||||
/// Unix seconds; `0` for a share no crawl of has finished.
|
||||
pub crawled_at: i64,
|
||||
pub attempted_at: i64,
|
||||
/// Playable files the last finished crawl stored.
|
||||
pub files: u64,
|
||||
/// Of those, how many no earlier crawl had seen.
|
||||
pub added: u64,
|
||||
/// Why the last attempt failed, when it did.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CrawlRecord {
|
||||
/// Whether the share is due another look: nothing has been tried within
|
||||
/// `age_secs` of `now`.
|
||||
pub fn is_stale(&self, now: i64, age_secs: i64) -> bool {
|
||||
now - self.crawled_at.max(self.attempted_at) >= age_secs
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfflineFile {
|
||||
pub revision_id: String,
|
||||
@@ -254,6 +299,16 @@ fn title_track_prefs_row(
|
||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||
const ABSOLUTE_SEASON: i64 = -1;
|
||||
|
||||
/// How long after an episode airs its guide keeps being asked about daily
|
||||
/// while the provider has no name for it.
|
||||
///
|
||||
/// A guide is otherwise trusted as long as a match, a month, and one fetched
|
||||
/// the week before an episode aired lists it unnamed — "Episode 2" with a
|
||||
/// date — until the month is up. A name usually follows the air date by a day
|
||||
/// or so; one that has not come in two weeks is not coming, and asking every
|
||||
/// day for the rest of time would not change that.
|
||||
const GUIDE_WAIT_DAYS: i64 = 14;
|
||||
|
||||
/// One catalog row: a file or folder inside a share.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CatalogNode {
|
||||
@@ -398,6 +453,12 @@ impl Catalog {
|
||||
tx.pragma_update(None, "user_version", 10)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 11 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V11)?;
|
||||
tx.pragma_update(None, "user_version", 11)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -459,10 +520,23 @@ impl Catalog {
|
||||
}
|
||||
|
||||
// Only files: a folder appearing is not something anyone watches.
|
||||
tx.execute(
|
||||
let now = unix_now();
|
||||
let added = tx.execute(
|
||||
"INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
|
||||
SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0",
|
||||
params![share_id, unix_now()],
|
||||
params![share_id, now],
|
||||
)?;
|
||||
let files = nodes.iter().filter(|node| !node.is_folder).count();
|
||||
tx.execute(
|
||||
"INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error)
|
||||
VALUES (?1, ?2, ?2, ?3, ?4, NULL)
|
||||
ON CONFLICT (share_id) DO UPDATE SET
|
||||
crawled_at = excluded.crawled_at,
|
||||
attempted_at = excluded.attempted_at,
|
||||
files = excluded.files,
|
||||
added = excluded.added,
|
||||
error = NULL",
|
||||
params![share_id, now, files as i64, added as i64],
|
||||
)?;
|
||||
|
||||
if !retain_offline {
|
||||
@@ -473,6 +547,41 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record that a crawl of `share_id` failed, keeping what the last
|
||||
/// finished one stored.
|
||||
pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error)
|
||||
VALUES (?1, 0, ?2, 0, 0, ?3)
|
||||
ON CONFLICT (share_id) DO UPDATE SET
|
||||
attempted_at = excluded.attempted_at,
|
||||
error = excluded.error",
|
||||
params![share_id, unix_now(), error],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the last crawl of each share came to, by share id. A share never
|
||||
/// crawled has no entry.
|
||||
pub fn crawls(&self) -> Result<HashMap<String, CrawlRecord>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT share_id, crawled_at, attempted_at, files, added, error FROM share_crawls",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
CrawlRecord {
|
||||
crawled_at: row.get(1)?,
|
||||
attempted_at: row.get(2)?,
|
||||
files: row.get::<_, i64>(3)?.max(0) as u64,
|
||||
added: row.get::<_, i64>(4)?.max(0) as u64,
|
||||
error: row.get(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Every file in a share, folders excluded, ordered for display.
|
||||
pub fn files(&self, share_id: &str) -> Result<Vec<CatalogNode>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
@@ -513,6 +622,17 @@ impl Catalog {
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Every share the catalog holds files for. One no longer configured was
|
||||
/// forgotten without [`Catalog::remove_share`] — by an older `pstr remove`
|
||||
/// — and its rows still show up in the library beside whatever replaced it.
|
||||
pub fn share_ids(&self) -> Result<Vec<String>> {
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?;
|
||||
let rows = statement.query_map([], |row| row.get(0))?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Drop everything a share contributed except where it was watched.
|
||||
///
|
||||
/// Watch state stays, as through a recrawl. It is history, and not only
|
||||
@@ -521,8 +641,34 @@ impl Catalog {
|
||||
/// only make it flicker. Nothing shows it — the library pairs positions
|
||||
/// with files, and the files are gone — until the share is added again,
|
||||
/// when it picks up where it left off.
|
||||
///
|
||||
/// It is also copied, newer wins as in a merge, onto every other share that
|
||||
/// holds the same file. A public link swapped for the account's own folder
|
||||
/// reaches the very same links under a new share id, and without the copy
|
||||
/// the swap reads as a library nobody has watched.
|
||||
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
||||
SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at
|
||||
FROM watch_state w
|
||||
JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id
|
||||
JOIN nodes other ON other.volume_id = own.volume_id
|
||||
AND other.link_id = own.link_id
|
||||
AND other.share_id <> own.share_id
|
||||
WHERE w.share_id = ?1
|
||||
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||
position_secs = excluded.position_secs,
|
||||
duration_secs = excluded.duration_secs,
|
||||
watched = excluded.watched,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE excluded.updated_at > watch_state.updated_at
|
||||
OR (excluded.updated_at = watch_state.updated_at
|
||||
AND (excluded.watched > watch_state.watched
|
||||
OR (excluded.watched = watch_state.watched
|
||||
AND excluded.position_secs > watch_state.position_secs)))",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||
@@ -532,6 +678,10 @@ impl Catalog {
|
||||
"DELETE FROM node_first_seen WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM share_crawls WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1030,14 +1180,27 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// When each matched title was last enriched, for the provider it is
|
||||
/// matched against. A title missing here has never been, or its match has
|
||||
/// changed since.
|
||||
/// matched against. A title missing here has never been, its match has
|
||||
/// changed since, or its guide is waiting on the name of an episode that
|
||||
/// has aired since — see `GUIDE_WAIT_DAYS`.
|
||||
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
|
||||
self.enrichment_ages_at(unix_now())
|
||||
}
|
||||
|
||||
fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT title_key, provider, enriched_at FROM title_metadata
|
||||
WHERE enriched_at IS NOT NULL",
|
||||
"SELECT title_key, provider, enriched_at FROM title_metadata t
|
||||
WHERE enriched_at IS NOT NULL
|
||||
AND NOT (enriched_at < ?1 - 86400
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM episode_metadata e
|
||||
WHERE e.title_key = t.title_key
|
||||
AND e.name IS NULL
|
||||
AND e.air_date <= date(?1, 'unixepoch')
|
||||
AND e.air_date >= date(t.enriched_at, 'unixepoch',
|
||||
'-' || ?2 || ' days')))",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
@@ -1052,6 +1215,36 @@ impl Catalog {
|
||||
ages.insert(title_key, (provider, enriched_at));
|
||||
}
|
||||
}
|
||||
drop(statement);
|
||||
|
||||
// A file that arrived after the guide was fetched, and that the guide
|
||||
// has no name for, is an episode the guide predates: the next one of a
|
||||
// running show. Asked again once — the refetch is newer than the file,
|
||||
// so a name the provider never gives does not make it ask forever.
|
||||
if !ages.is_empty() {
|
||||
let first_seen = self.first_seen()?;
|
||||
let guides = self.all_episode_metadata()?;
|
||||
for node in self.all_files()? {
|
||||
let Some(number) = node.parsed.episode else {
|
||||
continue;
|
||||
};
|
||||
let key = crate::library::title_key(&node.parsed.title);
|
||||
let Some((_, enriched_at)) = ages.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let seen = first_seen
|
||||
.get(&(node.share_id, node.link_id))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let named = guides
|
||||
.get(&key)
|
||||
.and_then(|guide| guide.get(node.parsed.season, number))
|
||||
.is_some_and(|episode| episode.name.is_some());
|
||||
if seen > *enriched_at && !named {
|
||||
ages.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ages)
|
||||
}
|
||||
}
|
||||
@@ -1844,6 +2037,129 @@ mod tests {
|
||||
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_newer_than_its_guide_that_the_guide_cannot_name_asks_for_the_guide_again() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.set_metadata(&MetadataRecord {
|
||||
title_key: "kusuriya".into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: 1_000,
|
||||
manual: false,
|
||||
})
|
||||
.expect("store");
|
||||
let enrichment = Enrichment {
|
||||
episodes: vec![EpisodeMetadata {
|
||||
season: Some(3),
|
||||
number: 1,
|
||||
absolute: None,
|
||||
entry_season: None,
|
||||
entry_number: None,
|
||||
name: Some("Locusts".into()),
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
}],
|
||||
backdrop_url: None,
|
||||
chain: Vec::new(),
|
||||
};
|
||||
catalog
|
||||
.set_enrichment("kusuriya", ProviderId::AniList, 1_000, &enrichment)
|
||||
.expect("enrich");
|
||||
let trusted = |catalog: &Catalog| {
|
||||
catalog
|
||||
.enrichment_ages()
|
||||
.expect("ages")
|
||||
.contains_key("kusuriya")
|
||||
};
|
||||
|
||||
// Newer than the guide, but the guide names it.
|
||||
let first = file("s", "e1", "Kusuriya S03E01.mkv");
|
||||
catalog
|
||||
.replace_share("s", std::slice::from_ref(&first))
|
||||
.expect("crawl");
|
||||
assert!(trusted(&catalog));
|
||||
|
||||
// The next episode, which the guide does not know yet.
|
||||
catalog
|
||||
.replace_share("s", &[first, file("s", "e2", "Kusuriya S03E02.mkv")])
|
||||
.expect("crawl again");
|
||||
assert!(!trusted(&catalog));
|
||||
|
||||
// Fetched again since, still without it: not asked about again.
|
||||
catalog
|
||||
.set_enrichment("kusuriya", ProviderId::AniList, unix_now() + 1, &enrichment)
|
||||
.expect("enrich again");
|
||||
assert!(trusted(&catalog));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guide_fetched_before_an_episode_had_a_name_is_asked_again_once_it_airs() {
|
||||
const DAY: i64 = 86_400;
|
||||
// 2026-10-04, when the guide was fetched; episode 2 airs on the 9th.
|
||||
let fetched = 1_791_072_000;
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.set_metadata(&MetadataRecord {
|
||||
title_key: "kusuriya".into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: fetched,
|
||||
manual: false,
|
||||
})
|
||||
.expect("store");
|
||||
let episode = |number: u32, name: Option<&str>, air_date: &str| EpisodeMetadata {
|
||||
season: Some(3),
|
||||
number,
|
||||
absolute: None,
|
||||
entry_season: Some(1),
|
||||
entry_number: Some(number),
|
||||
name: name.map(str::to_owned),
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: Some(air_date.into()),
|
||||
};
|
||||
let enrichment = Enrichment {
|
||||
episodes: vec![
|
||||
episode(1, Some("Locusts"), "2026-10-02"),
|
||||
episode(2, None, "2026-10-09"),
|
||||
],
|
||||
backdrop_url: None,
|
||||
chain: Vec::new(),
|
||||
};
|
||||
catalog
|
||||
.set_enrichment("kusuriya", ProviderId::AniList, fetched, &enrichment)
|
||||
.expect("enrich");
|
||||
let trusted = |catalog: &Catalog, now: i64| {
|
||||
catalog
|
||||
.enrichment_ages_at(now)
|
||||
.expect("ages")
|
||||
.contains_key("kusuriya")
|
||||
};
|
||||
|
||||
// Before episode 2 airs, a nameless future episode is no reason.
|
||||
assert!(trusted(&catalog, fetched + 4 * DAY));
|
||||
// Once it has aired, the guide is asked again.
|
||||
assert!(!trusted(&catalog, fetched + 8 * DAY));
|
||||
|
||||
// Asked on the 12th and still nameless: not again the same day…
|
||||
let refetched = fetched + 8 * DAY;
|
||||
catalog
|
||||
.set_enrichment("kusuriya", ProviderId::AniList, refetched, &enrichment)
|
||||
.expect("enrich again");
|
||||
assert!(trusted(&catalog, refetched + DAY / 2));
|
||||
// …but the next.
|
||||
assert!(!trusted(&catalog, refetched + DAY + 1));
|
||||
|
||||
// Two weeks past the air date it is given up on.
|
||||
let given_up = fetched + 21 * DAY;
|
||||
catalog
|
||||
.set_enrichment("kusuriya", ProviderId::AniList, given_up, &enrichment)
|
||||
.expect("enrich late");
|
||||
assert!(trusted(&catalog, given_up + 2 * DAY));
|
||||
}
|
||||
|
||||
/// Details round-trip, and refreshing a match's details — the same entry
|
||||
/// stored again — keeps what enrichment found for it.
|
||||
#[test]
|
||||
@@ -1920,6 +2236,91 @@ mod tests {
|
||||
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crawl_records_when_it_finished_and_what_it_added() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
catalog
|
||||
.replace_share(
|
||||
"s",
|
||||
&[
|
||||
file("s", "a", "Show S01E01.mkv"),
|
||||
file("s", "b", "Show S01E02.mkv"),
|
||||
],
|
||||
)
|
||||
.expect("recrawl");
|
||||
let record = &catalog.crawls().expect("read")["s"];
|
||||
assert!(record.crawled_at > 0);
|
||||
assert_eq!(record.attempted_at, record.crawled_at);
|
||||
assert_eq!((record.files, record.added), (2, 1));
|
||||
assert_eq!(record.error, None);
|
||||
}
|
||||
|
||||
/// A failure says why without forgetting what the last good crawl found,
|
||||
/// and the next good crawl clears it.
|
||||
#[test]
|
||||
fn a_failed_crawl_keeps_the_last_good_one_until_the_next() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
catalog
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE share_crawls SET crawled_at = 5, attempted_at = 5",
|
||||
[],
|
||||
)
|
||||
.expect("age it");
|
||||
catalog
|
||||
.record_crawl_failure("s", "link expired")
|
||||
.expect("fail");
|
||||
let record = catalog.crawls().expect("read")["s"].clone();
|
||||
assert_eq!((record.crawled_at, record.files), (5, 1));
|
||||
assert!(record.attempted_at > 5);
|
||||
assert_eq!(record.error.as_deref(), Some("link expired"));
|
||||
assert!(!record.is_stale(record.attempted_at + 10, 60));
|
||||
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("recrawl");
|
||||
assert_eq!(catalog.crawls().expect("read")["s"].error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_share_forgets_its_crawls() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
catalog.remove_share("s").expect("remove");
|
||||
assert!(catalog.crawls().expect("read").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v11_opens_a_v10_catalog() {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
for migration in [
|
||||
MIGRATION_V1,
|
||||
MIGRATION_V2,
|
||||
MIGRATION_V3,
|
||||
MIGRATION_V4,
|
||||
MIGRATION_V5,
|
||||
MIGRATION_V6,
|
||||
MIGRATION_V7,
|
||||
MIGRATION_V8,
|
||||
MIGRATION_V9,
|
||||
MIGRATION_V10,
|
||||
] {
|
||||
conn.execute_batch(migration).expect("migrate");
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 10)
|
||||
.expect("set version");
|
||||
let catalog = Catalog::from_connection(conn).expect("upgrade");
|
||||
assert!(catalog.crawls().expect("read").is_empty());
|
||||
}
|
||||
|
||||
/// A file keeps the moment it first appeared across recrawls, and one
|
||||
/// that was there before this was recorded reads as 0.
|
||||
#[test]
|
||||
@@ -2460,6 +2861,66 @@ mod tests {
|
||||
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_share_hands_its_watch_state_to_another_share_holding_the_same_file() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let at = |position_secs: f64, updated_at: i64| WatchState {
|
||||
position_secs,
|
||||
duration_secs: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at,
|
||||
};
|
||||
catalog
|
||||
.replace_share(
|
||||
"link",
|
||||
&[
|
||||
file("link", "a", "Show S01E01.mkv"),
|
||||
file("link", "b", "Show S01E02.mkv"),
|
||||
file("link", "gone", "Show S01E03.mkv"),
|
||||
],
|
||||
)
|
||||
.expect("crawl the link");
|
||||
catalog
|
||||
.replace_share(
|
||||
"folder",
|
||||
&[
|
||||
file("folder", "a", "Show S01E01.mkv"),
|
||||
file("folder", "b", "Show S01E02.mkv"),
|
||||
],
|
||||
)
|
||||
.expect("crawl the folder");
|
||||
catalog
|
||||
.set_watch_state("link", "a", &at(100.0, 10))
|
||||
.unwrap();
|
||||
catalog
|
||||
.set_watch_state("link", "b", &at(100.0, 10))
|
||||
.unwrap();
|
||||
catalog
|
||||
.set_watch_state("link", "gone", &at(100.0, 10))
|
||||
.unwrap();
|
||||
// Watched through the folder since: the link's older position must
|
||||
// not overwrite it.
|
||||
catalog
|
||||
.set_watch_state("folder", "b", &at(900.0, 20))
|
||||
.unwrap();
|
||||
|
||||
catalog.remove_share("link").expect("remove");
|
||||
|
||||
assert_eq!(
|
||||
catalog.watch_state("folder", "a").unwrap(),
|
||||
Some(at(100.0, 10))
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.watch_state("folder", "b").unwrap(),
|
||||
Some(at(900.0, 20))
|
||||
);
|
||||
assert_eq!(catalog.watch_state("folder", "gone").unwrap(), None);
|
||||
assert_eq!(
|
||||
catalog.watch_state("link", "a").unwrap(),
|
||||
Some(at(100.0, 10))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_takes_only_states_newer_than_the_recorded_ones() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
|
||||
@@ -46,6 +46,11 @@ pub enum Error {
|
||||
/// mailbox password.
|
||||
#[error("{0}")]
|
||||
SignIn(String),
|
||||
|
||||
/// A public link with a password of its own was opened without one. The
|
||||
/// handshake refusal says nothing a viewer can act on; this does.
|
||||
#[error("This link has a password of its own. Enter it to add the link.")]
|
||||
PasswordNeeded,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod library;
|
||||
pub mod metadata;
|
||||
pub mod naming;
|
||||
pub mod prefs;
|
||||
pub mod profile;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod sync;
|
||||
|
||||
@@ -189,10 +189,44 @@ impl Title {
|
||||
.max_by_key(|episode| episode.last_played())
|
||||
}
|
||||
|
||||
/// What a click on the poster should play: whatever is part-watched, else
|
||||
/// the first unwatched episode, else the first episode.
|
||||
/// What Continue watching offers for this title: the part-watched episode,
|
||||
/// or the one after the episode finished last.
|
||||
///
|
||||
/// Read off whichever episode was touched last. Finishing an episode is
|
||||
/// the most ordinary moment to want the next, and a shelf that dropped the
|
||||
/// show right then was missing its point. An episode reset to the start —
|
||||
/// which is what leaving Continue watching does — says the viewer is done
|
||||
/// with it, so the title is left out. One opened for a few seconds says
|
||||
/// nothing either way and is passed over.
|
||||
pub fn continue_with(&self) -> Option<&Episode> {
|
||||
let latest = self
|
||||
.episodes()
|
||||
.filter(|episode| {
|
||||
episode.watch.is_some_and(|watch| {
|
||||
watch.position_secs == 0.0
|
||||
|| watch.position_secs >= RESUME_FLOOR_SECS
|
||||
|| episode.is_watched()
|
||||
})
|
||||
})
|
||||
.max_by_key(|episode| episode.last_played())?;
|
||||
if latest.resume_at().is_some() {
|
||||
return Some(latest);
|
||||
}
|
||||
if !latest.is_watched() {
|
||||
return None;
|
||||
}
|
||||
let at = self.position_of(&latest.node.share_id, &latest.node.link_id)?;
|
||||
self.episodes()
|
||||
.skip(at + 1)
|
||||
.find(|episode| !episode.is_watched())
|
||||
}
|
||||
|
||||
/// What a click on the poster should play: what Continue watching offers,
|
||||
/// else whatever is part-watched, else the first unwatched episode, else
|
||||
/// the first episode.
|
||||
pub fn next_up(&self) -> Option<&Episode> {
|
||||
self.resume()
|
||||
self.continue_with()
|
||||
.or_else(|| self.resume())
|
||||
.or_else(|| self.episodes().find(|episode| !episode.is_watched()))
|
||||
.or_else(|| self.episodes().next())
|
||||
}
|
||||
@@ -357,13 +391,13 @@ impl Library {
|
||||
self.titles.iter().find(|title| title.key == key)
|
||||
}
|
||||
|
||||
/// Titles with something part-watched, most recent first. The top row of
|
||||
/// the library.
|
||||
/// Titles with something to go on with — see [`Title::continue_with`] —
|
||||
/// most recent first. The top row of the library.
|
||||
pub fn continue_watching(&self) -> Vec<&Title> {
|
||||
let mut titles: Vec<&Title> = self
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|title| title.resume().is_some())
|
||||
.filter(|title| title.continue_with().is_some())
|
||||
.collect();
|
||||
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
|
||||
titles
|
||||
@@ -688,6 +722,64 @@ mod tests {
|
||||
assert_eq!(library.continue_watching().len(), 1);
|
||||
}
|
||||
|
||||
fn three_episodes() -> Vec<CatalogNode> {
|
||||
vec![
|
||||
node("s", "1", "Show - S01E01", Some(1), Some(1)),
|
||||
node("s", "2", "Show - S01E02", Some(1), Some(2)),
|
||||
node("s", "3", "Show - S01E03", Some(1), Some(3)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_episode_puts_the_next_one_in_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
let next = library.titles[0].continue_with().unwrap();
|
||||
assert_eq!(next.node.link_id, "2");
|
||||
assert_eq!(next.resume_at(), None);
|
||||
assert_eq!(library.continue_watching().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_last_episode_leaves_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
for link in ["1", "2", "3"] {
|
||||
states.insert(("s".into(), link.into()), watch(1440.0, 1440.0, true, 10));
|
||||
}
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert!(library.continue_watching().is_empty());
|
||||
}
|
||||
|
||||
/// Leaving Continue watching resets the episode to the start. Offering the
|
||||
/// one after the last finished episode instead would bring the title
|
||||
/// straight back.
|
||||
#[test]
|
||||
fn an_episode_reset_to_the_start_keeps_its_title_out_of_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
|
||||
states.insert(("s".into(), "2".into()), watch(0.0, 1440.0, false, 20));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert!(library.continue_watching().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_few_seconds_of_the_next_episode_do_not_hide_the_one_part_watched() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(700.0, 1440.0, false, 10));
|
||||
states.insert(("s".into(), "2".into()), watch(4.0, 1440.0, false, 20));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_episode_after_the_finished_one_is_offered_past_an_older_unwatched_one() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "2".into()), watch(1440.0, 1440.0, true, 10));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||
let nodes = vec![
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
//! The rest of what makes the app look the same on every device: the shares,
|
||||
//! the settings, the hand-picked metadata matches and the per-show track
|
||||
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
|
||||
//!
|
||||
//! ## Registers
|
||||
//!
|
||||
//! Each synced thing is one *register*: a key (`share/<id>`,
|
||||
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
|
||||
//! last changed. Merging is the same rule as watch history — the newer
|
||||
//! `updated_at` wins, per key — so it is idempotent and order-free across any
|
||||
//! number of devices. A removed share is a register with no value, so a
|
||||
//! removal travels like any other change.
|
||||
//!
|
||||
//! ## Noticing changes
|
||||
//!
|
||||
//! Nothing that writes a setting or a share knows about sync. Instead
|
||||
//! `sync-state.json` remembers every register as last synced, and each sync
|
||||
//! compares it with what is on this device: whatever differs changed since,
|
||||
//! and is stamped with the time of the sync. That costs a change made between
|
||||
//! syncs its exact time, which matters only when two devices change the same
|
||||
//! setting within one sync interval of each other.
|
||||
//!
|
||||
//! The first sync of an installation stamps what it finds at zero rather than
|
||||
//! now. A fresh install holds only defaults, and those must not outrank the
|
||||
//! library every other device already agreed on; what it has that nobody else
|
||||
//! does is still added, since nothing older competes with it.
|
||||
//!
|
||||
//! ## What does not travel
|
||||
//!
|
||||
//! The volume and mute, which belong to the speakers in front of the viewer,
|
||||
//! and every setting the front ends keep themselves: the cache budget, Wi-Fi
|
||||
//! only, background audio, hardware decoding, window geometry.
|
||||
//!
|
||||
//! ## Secrets
|
||||
//!
|
||||
//! A share's register carries its link, fragment included, and its custom
|
||||
//! password, and the TMDB API key travels too: without them another device
|
||||
//! could list the share but never open it. The file holding them is in the
|
||||
//! viewer's own Drive, end-to-end encrypted with their account's keys, which is
|
||||
//! the same trust the credential store gives them locally; they are never
|
||||
//! written anywhere else in plain text.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::catalog::{Catalog, TitleTrackPrefs};
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::Result;
|
||||
use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata};
|
||||
use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
|
||||
|
||||
const SHARE: &str = "share/";
|
||||
const TRACKS: &str = "tracks/";
|
||||
const MATCH: &str = "match/";
|
||||
|
||||
/// The settings files that travel, by register, and the fields in each that
|
||||
/// stay on the device that wrote them.
|
||||
const SETTINGS: [(&str, &str, &[&str]); 3] = [
|
||||
("settings/playback", "playback.json", &["volume", "muted"]),
|
||||
("settings/appearance", "appearance.json", &[]),
|
||||
("settings/metadata", "metadata.json", &[]),
|
||||
];
|
||||
|
||||
const METADATA_SETTINGS: &str = "settings/metadata";
|
||||
|
||||
/// One synced value and when it last changed.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) struct Register {
|
||||
/// `None` is a removal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
pub(crate) type Registers = BTreeMap<String, Register>;
|
||||
|
||||
/// What this installation last synced, kept beside its sync identity.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct State {
|
||||
registers: Registers,
|
||||
}
|
||||
|
||||
/// A share as it travels: everything another device needs to open it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SyncedShare {
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
token: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
custom_password: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
folder: Option<AccountFolder>,
|
||||
}
|
||||
|
||||
/// A match the viewer picked by hand. Automatic ones are not synced: every
|
||||
/// device with enrichment on finds them again, and they expire anyway.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SyncedMatch {
|
||||
provider: ProviderId,
|
||||
metadata: Option<TitleMetadata>,
|
||||
fetched_at: i64,
|
||||
}
|
||||
|
||||
/// What applying other devices' registers changed here, for the front end to
|
||||
/// act on: reopen and crawl shares, repaint, reload the library.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProfileChanges {
|
||||
/// Shares this device did not have, now configured. Not yet crawled.
|
||||
pub shares_added: Vec<String>,
|
||||
/// Shares whose name or secrets changed.
|
||||
pub shares_changed: Vec<String>,
|
||||
/// Shares removed elsewhere, now gone from the configuration here. Their
|
||||
/// catalog rows and offline files are the front end's to drop, the same
|
||||
/// way it does for a share removed on this device.
|
||||
pub shares_removed: Vec<String>,
|
||||
/// Whether any settings file or the TMDB key changed.
|
||||
pub settings: bool,
|
||||
/// Hand-picked matches and per-show track choices taken.
|
||||
pub titles: usize,
|
||||
}
|
||||
|
||||
impl ProfileChanges {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
pub fn shares(&self) -> bool {
|
||||
!(self.shares_added.is_empty()
|
||||
&& self.shares_changed.is_empty()
|
||||
&& self.shares_removed.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The local side of profile sync: where the state file lives, and the stores
|
||||
/// registers are read from and applied to.
|
||||
pub(crate) struct Profile<'a> {
|
||||
pub store: &'a ShareStore,
|
||||
pub catalog: &'a Mutex<Catalog>,
|
||||
}
|
||||
|
||||
/// What is on this device, register by register.
|
||||
#[derive(Debug, Default)]
|
||||
struct Observed {
|
||||
values: BTreeMap<String, Value>,
|
||||
/// Keys that exist but could not be read — a share whose secret will not
|
||||
/// decrypt. Neither a change nor a removal.
|
||||
unknown: BTreeSet<String>,
|
||||
/// Whether the share list itself could be read. Without it, a share
|
||||
/// missing from `values` says nothing about whether it was removed.
|
||||
shares_listed: bool,
|
||||
}
|
||||
|
||||
fn state_file(dirs: &AppDirs) -> PathBuf {
|
||||
dirs.config.join("sync-state.json")
|
||||
}
|
||||
|
||||
fn tmdb_secret() -> String {
|
||||
format!("metadata-{}", ProviderId::Tmdb.as_str())
|
||||
}
|
||||
|
||||
const TMDB_KEY: &str = "secret/metadata-tmdb";
|
||||
|
||||
impl Profile<'_> {
|
||||
fn dirs(&self) -> &AppDirs {
|
||||
&self.store.dirs
|
||||
}
|
||||
|
||||
/// Read the state, notice what changed here since, take what other
|
||||
/// devices have newer, and write the state back. Returns the registers to
|
||||
/// upload and what changed on this device.
|
||||
pub fn sync<'r>(
|
||||
&self,
|
||||
remote: impl IntoIterator<Item = &'r Registers>,
|
||||
now: i64,
|
||||
) -> Result<(Registers, ProfileChanges)> {
|
||||
let path = state_file(self.dirs());
|
||||
let stored = read_json::<State>(&path)?;
|
||||
let first = stored.is_none();
|
||||
let mut state = stored.unwrap_or_default();
|
||||
|
||||
let observed = self.observe();
|
||||
stamp(&mut state.registers, observed, if first { 0 } else { now });
|
||||
let winners = merge(&state.registers, remote);
|
||||
let changes = self.apply(&mut state.registers, winners);
|
||||
write_json(&path, &state)?;
|
||||
Ok((state.registers, changes))
|
||||
}
|
||||
|
||||
fn observe(&self) -> Observed {
|
||||
let mut observed = Observed::default();
|
||||
for (key, file, local) in SETTINGS {
|
||||
match read_json::<Value>(&self.dirs().config.join(file)) {
|
||||
Ok(Some(value)) => {
|
||||
observed
|
||||
.values
|
||||
.insert(key.to_owned(), without(value, local));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, file, "a settings file was not synced");
|
||||
observed.unknown.insert(key.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
match self.store.secrets.get(&tmdb_secret()) {
|
||||
Ok(Some(key)) => {
|
||||
observed
|
||||
.values
|
||||
.insert(TMDB_KEY.to_owned(), Value::String(key));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => {
|
||||
observed.unknown.insert(TMDB_KEY.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(shares) = self.store.list() {
|
||||
observed.shares_listed = true;
|
||||
for share in shares {
|
||||
let key = format!("{SHARE}{}", share.id);
|
||||
let secrets = if share.folder.is_some() {
|
||||
None
|
||||
} else {
|
||||
match self.store.secrets_of(&share.id) {
|
||||
Ok(secrets) => Some(secrets),
|
||||
Err(_) => {
|
||||
observed.unknown.insert(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let synced = SyncedShare {
|
||||
name: share.name,
|
||||
token: share.token,
|
||||
url: secrets.as_ref().map(|secrets| secrets.url.clone()),
|
||||
custom_password: secrets.and_then(|secrets| secrets.custom_password),
|
||||
folder: share.folder,
|
||||
};
|
||||
if let Ok(value) = serde_json::to_value(synced) {
|
||||
observed.values.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let catalog = self.catalog.lock();
|
||||
if let Ok(tracks) = catalog.all_title_track_prefs() {
|
||||
for (title, prefs) in tracks {
|
||||
if let Ok(value) = serde_json::to_value(prefs) {
|
||||
observed.values.insert(format!("{TRACKS}{title}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(records) = catalog.all_metadata() {
|
||||
for (title, record) in records {
|
||||
if !record.manual {
|
||||
continue;
|
||||
}
|
||||
let synced = SyncedMatch {
|
||||
provider: record.provider,
|
||||
metadata: record.metadata,
|
||||
fetched_at: record.fetched_at,
|
||||
};
|
||||
if let Ok(value) = serde_json::to_value(synced) {
|
||||
observed.values.insert(format!("{MATCH}{title}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
observed
|
||||
}
|
||||
|
||||
/// Apply each winning register here, recording it in `registers` once it
|
||||
/// took. One that fails is left out, so the next sync tries it again.
|
||||
fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges {
|
||||
let mut changes = ProfileChanges::default();
|
||||
for (key, register) in winners {
|
||||
match self.apply_one(&key, register.value.as_ref(), &mut changes) {
|
||||
Ok(()) => {
|
||||
registers.insert(key, register);
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, key, "a synced change was not applied"),
|
||||
}
|
||||
}
|
||||
changes
|
||||
}
|
||||
|
||||
fn apply_one(
|
||||
&self,
|
||||
key: &str,
|
||||
value: Option<&Value>,
|
||||
changes: &mut ProfileChanges,
|
||||
) -> Result<()> {
|
||||
if let Some(id) = key.strip_prefix(SHARE) {
|
||||
return self.apply_share(id, value, changes);
|
||||
}
|
||||
if let Some(title) = key.strip_prefix(TRACKS) {
|
||||
if let Some(value) = value {
|
||||
let prefs: TitleTrackPrefs = parse(value)?;
|
||||
self.catalog.lock().set_title_track_prefs(title, &prefs)?;
|
||||
changes.titles += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(title) = key.strip_prefix(MATCH) {
|
||||
if let Some(value) = value {
|
||||
let synced: SyncedMatch = parse(value)?;
|
||||
self.catalog.lock().set_metadata(&MetadataRecord {
|
||||
title_key: title.to_owned(),
|
||||
provider: synced.provider,
|
||||
metadata: synced.metadata,
|
||||
fetched_at: synced.fetched_at,
|
||||
manual: true,
|
||||
})?;
|
||||
changes.titles += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if key == TMDB_KEY {
|
||||
match value.and_then(Value::as_str) {
|
||||
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
|
||||
None => self.store.secrets.delete(&tmdb_secret())?,
|
||||
}
|
||||
changes.settings = true;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) {
|
||||
let Some(Value::Object(incoming)) = value else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = self.dirs().config.join(file);
|
||||
let before = read_json::<Value>(&path)?;
|
||||
let mut merged = match &before {
|
||||
Some(Value::Object(existing)) => existing.clone(),
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
for (field, field_value) in incoming {
|
||||
if !local.contains(&field.as_str()) {
|
||||
merged.insert(field.clone(), field_value.clone());
|
||||
}
|
||||
}
|
||||
let merged = Value::Object(merged);
|
||||
if key == METADATA_SETTINGS {
|
||||
self.metadata_settings_changing(before.as_ref(), &merged)?;
|
||||
}
|
||||
write_json(&path, &merged)?;
|
||||
changes.settings = true;
|
||||
return Ok(());
|
||||
}
|
||||
// A register a newer build writes. Kept, so it travels on.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_share(
|
||||
&self,
|
||||
id: &str,
|
||||
value: Option<&Value>,
|
||||
changes: &mut ProfileChanges,
|
||||
) -> Result<()> {
|
||||
let existing = self.store.list()?.into_iter().find(|share| share.id == id);
|
||||
let Some(value) = value else {
|
||||
if existing.is_some() {
|
||||
self.store.remove(id)?;
|
||||
changes.shares_removed.push(id.to_owned());
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let synced: SyncedShare = parse(value)?;
|
||||
let expected = match (&synced.folder, &synced.url) {
|
||||
(Some(folder), _) => folder.share_id(),
|
||||
(None, Some(_)) => format!("share-{}", synced.token),
|
||||
(None, None) => return Err(invalid("a synced link has no URL")),
|
||||
};
|
||||
if expected != id {
|
||||
return Err(invalid("a synced share's id does not match what it opens"));
|
||||
}
|
||||
let secrets = synced.url.map(|url| ShareSecrets {
|
||||
url,
|
||||
custom_password: synced.custom_password,
|
||||
});
|
||||
let share = Share {
|
||||
id: id.to_owned(),
|
||||
name: synced.name,
|
||||
token: synced.token,
|
||||
has_custom_password: secrets
|
||||
.as_ref()
|
||||
.is_some_and(|secrets| secrets.custom_password.is_some()),
|
||||
folder: synced.folder,
|
||||
};
|
||||
if existing.as_ref() == Some(&share)
|
||||
&& secrets.as_ref().is_none_or(|secrets| {
|
||||
self.store
|
||||
.secrets_of(id)
|
||||
.is_ok_and(|stored| &stored == secrets)
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.store.put_synced(share, secrets.as_ref())?;
|
||||
if existing.is_some() {
|
||||
changes.shares_changed.push(id.to_owned());
|
||||
} else {
|
||||
changes.shares_added.push(id.to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turning enrichment off or switching provider drops every stored answer
|
||||
/// on the device that did it, which the settings page also does here.
|
||||
fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> {
|
||||
let config = |value: Option<&Value>| -> MetadataConfig {
|
||||
value
|
||||
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (before, after) = (config(before), config(Some(after)));
|
||||
if !after.enabled || before.provider != after.provider {
|
||||
self.catalog.lock().clear_metadata()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Record what changed on this device since the last sync, stamped `now`.
|
||||
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
|
||||
for (key, register) in registers.iter_mut() {
|
||||
if register.value.is_some()
|
||||
&& key.starts_with(SHARE)
|
||||
&& observed.shares_listed
|
||||
&& !observed.values.contains_key(key)
|
||||
&& !observed.unknown.contains(key)
|
||||
{
|
||||
*register = Register {
|
||||
value: None,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (key, value) in observed.values {
|
||||
let unchanged = registers
|
||||
.get(&key)
|
||||
.is_some_and(|register| same(&key, register.value.as_ref(), Some(&value)));
|
||||
if !unchanged {
|
||||
registers.insert(
|
||||
key,
|
||||
Register {
|
||||
value: Some(value),
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two values of one register are the same choice.
|
||||
///
|
||||
/// A match is compared by what it points at: its stored details move every
|
||||
/// time the provider is asked again, and a register re-stamped by every
|
||||
/// refresh would bounce between devices for ever.
|
||||
fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool {
|
||||
if key.starts_with(MATCH) {
|
||||
let pointer = |value: Option<&Value>| {
|
||||
value.map(|value| {
|
||||
(
|
||||
value.get("provider").cloned(),
|
||||
value
|
||||
.get("metadata")
|
||||
.and_then(|metadata| metadata.get("remote_id"))
|
||||
.cloned(),
|
||||
)
|
||||
})
|
||||
};
|
||||
return pointer(a) == pointer(b);
|
||||
}
|
||||
a == b
|
||||
}
|
||||
|
||||
/// The registers from other devices that are newer than this one's.
|
||||
fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers {
|
||||
let mut winners = Registers::new();
|
||||
for registers in remote {
|
||||
for (key, register) in registers {
|
||||
let current = winners.get(key).or_else(|| local.get(key));
|
||||
if current.is_none_or(|current| newer(register, current)) {
|
||||
winners.insert(key.clone(), register.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
winners
|
||||
}
|
||||
|
||||
/// Newer wins; a tie between two different values goes the same way on every
|
||||
/// device, or two of them would each keep their own.
|
||||
fn newer(candidate: &Register, current: &Register) -> bool {
|
||||
if candidate.updated_at != current.updated_at {
|
||||
return candidate.updated_at > current.updated_at;
|
||||
}
|
||||
let order = |register: &Register| {
|
||||
register
|
||||
.value
|
||||
.as_ref()
|
||||
.map(Value::to_string)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
order(candidate) > order(current)
|
||||
}
|
||||
|
||||
/// `value` without the fields that stay on this device.
|
||||
fn without(value: Value, local: &[&str]) -> Value {
|
||||
match value {
|
||||
Value::Object(mut fields) => {
|
||||
for field in local {
|
||||
fields.remove(*field);
|
||||
}
|
||||
Value::Object(fields)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
|
||||
serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string()))
|
||||
}
|
||||
|
||||
fn invalid(reason: &str) -> crate::Error {
|
||||
crate::Error::Config(format!("synced setting: {reason}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::shares::SecretStore;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemorySecrets(Mutex<HashMap<String, String>>);
|
||||
|
||||
impl SecretStore for MemorySecrets {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
Ok(self.0.lock().get(key).cloned())
|
||||
}
|
||||
fn delete(&self, key: &str) -> Result<()> {
|
||||
self.0.lock().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One installation's configuration and catalog.
|
||||
pub(crate) struct Device {
|
||||
root: PathBuf,
|
||||
pub store: ShareStore,
|
||||
pub catalog: Mutex<Catalog>,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub(crate) fn new(tag: &str) -> Self {
|
||||
let mut bytes = [0_u8; 8];
|
||||
getrandom::fill(&mut bytes).unwrap();
|
||||
let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}"));
|
||||
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
|
||||
Self {
|
||||
root,
|
||||
store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())),
|
||||
catalog: Mutex::new(Catalog::in_memory().unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> {
|
||||
self.catalog.lock()
|
||||
}
|
||||
|
||||
pub(crate) fn profile(&self) -> Profile<'_> {
|
||||
Profile {
|
||||
store: &self.store,
|
||||
catalog: &self.catalog,
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) {
|
||||
self.profile().sync(remote.iter().copied(), now).unwrap()
|
||||
}
|
||||
|
||||
fn share_names(&self) -> Vec<String> {
|
||||
self.store
|
||||
.list()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|share| share.name)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Device {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart";
|
||||
|
||||
#[test]
|
||||
fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.sync(&[], 10);
|
||||
laptop.store.add("Anime", LINK, Some("hunter2")).unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 20);
|
||||
|
||||
let (_, changes) = phone.sync(&[&sent], 30);
|
||||
|
||||
assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]);
|
||||
assert_eq!(phone.share_names(), ["Anime"]);
|
||||
let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap();
|
||||
assert_eq!(secrets.url, LINK);
|
||||
assert_eq!(secrets.custom_password.as_deref(), Some("hunter2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_removed_on_one_device_is_removed_on_the_others() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.store.add("Anime", LINK, None).unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 10);
|
||||
let (phone_sent, _) = phone.sync(&[&sent], 20);
|
||||
|
||||
laptop.store.remove("share-ABCDEFGHIJ").unwrap();
|
||||
let (sent, _) = laptop.sync(&[&phone_sent], 30);
|
||||
let (_, changes) = phone.sync(&[&sent], 40);
|
||||
|
||||
assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]);
|
||||
assert!(phone.share_names().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_installation_takes_the_library_rather_than_its_defaults() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.sync(&[], 10);
|
||||
let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap();
|
||||
prefs.autoplay_next = false;
|
||||
prefs.speed = 1.5;
|
||||
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 20);
|
||||
|
||||
// The new phone has written its defaults before it ever synced.
|
||||
crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap();
|
||||
let (_, changes) = phone.sync(&[&sent], 30);
|
||||
|
||||
assert!(changes.settings);
|
||||
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
|
||||
assert!(!taken.autoplay_next);
|
||||
assert_eq!(taken.speed, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_and_mute_stay_on_the_device_that_set_them() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.sync(&[], 10);
|
||||
let mut prefs = crate::prefs::PlaybackPrefs {
|
||||
volume: 20.0,
|
||||
muted: true,
|
||||
..Default::default()
|
||||
};
|
||||
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 20);
|
||||
prefs.volume = 80.0;
|
||||
prefs.muted = false;
|
||||
prefs.subtitles = false;
|
||||
crate::prefs::save(&phone.store.dirs, &prefs).unwrap();
|
||||
phone.sync(&[], 5);
|
||||
|
||||
phone.sync(&[&sent], 30);
|
||||
|
||||
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
|
||||
assert_eq!(taken.volume, 80.0);
|
||||
assert!(!taken.muted);
|
||||
assert!(taken.subtitles, "the laptop's later choice wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newer_change_wins_whichever_device_syncs_first() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.sync(&[], 1);
|
||||
phone.sync(&[], 1);
|
||||
let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap();
|
||||
appearance.gradients = false;
|
||||
crate::appearance::save(&laptop.store.dirs, &appearance).unwrap();
|
||||
let (from_laptop, _) = laptop.sync(&[], 10);
|
||||
appearance.gradients = true;
|
||||
appearance.accent = crate::appearance::Accent::Peach;
|
||||
crate::appearance::save(&phone.store.dirs, &appearance).unwrap();
|
||||
let (from_phone, _) = phone.sync(&[&from_laptop], 20);
|
||||
laptop.sync(&[&from_phone], 30);
|
||||
|
||||
for device in [&laptop, &phone] {
|
||||
let seen = crate::appearance::load(&device.store.dirs).unwrap();
|
||||
assert_eq!(seen.accent, crate::appearance::Accent::Peach);
|
||||
assert!(seen.gradients);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_device_sends_the_same_registers_again() {
|
||||
let laptop = Device::new("laptop");
|
||||
laptop.store.add("Anime", LINK, None).unwrap();
|
||||
let (first, _) = laptop.sync(&[], 10);
|
||||
let (second, changes) = laptop.sync(&[&first], 20);
|
||||
assert_eq!(first, second);
|
||||
assert!(changes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() {
|
||||
let laptop = Device::new("laptop");
|
||||
laptop.store.add("Anime", LINK, None).unwrap();
|
||||
laptop.sync(&[], 10);
|
||||
laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 20);
|
||||
assert!(sent["share/share-ABCDEFGHIJ"].value.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_picked_match_and_a_shows_track_choice_travel() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.sync(&[], 1);
|
||||
let metadata = TitleMetadata {
|
||||
provider: ProviderId::AniList,
|
||||
remote_id: "21".to_owned(),
|
||||
name: "One Piece".to_owned(),
|
||||
original_name: None,
|
||||
overview: None,
|
||||
year: Some(1999),
|
||||
kind: crate::library::TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
url: None,
|
||||
details: None,
|
||||
};
|
||||
laptop
|
||||
.catalog
|
||||
.lock()
|
||||
.set_metadata(&MetadataRecord {
|
||||
title_key: "one piece".to_owned(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(metadata),
|
||||
fetched_at: 5,
|
||||
manual: true,
|
||||
})
|
||||
.unwrap();
|
||||
let tracks = TitleTrackPrefs {
|
||||
audio_language: Some("jpn".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
laptop
|
||||
.catalog
|
||||
.lock()
|
||||
.set_title_track_prefs("one piece", &tracks)
|
||||
.unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 10);
|
||||
|
||||
let (_, changes) = phone.sync(&[&sent], 20);
|
||||
|
||||
assert_eq!(changes.titles, 2);
|
||||
let catalog = phone.catalog.lock();
|
||||
let record = catalog.metadata("one piece").unwrap().unwrap();
|
||||
assert!(record.manual);
|
||||
assert_eq!(record.metadata.unwrap().remote_id, "21");
|
||||
assert_eq!(
|
||||
catalog.title_track_prefs("one piece").unwrap(),
|
||||
Some(tracks)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_synced_share_that_names_another_id_is_refused() {
|
||||
let phone = Device::new("phone");
|
||||
let mut forged = Registers::new();
|
||||
forged.insert(
|
||||
"share/share-OTHER".to_owned(),
|
||||
Register {
|
||||
value: Some(serde_json::json!({
|
||||
"name": "x", "token": "ABCDEFGHIJ", "url": LINK
|
||||
})),
|
||||
updated_at: 10,
|
||||
},
|
||||
);
|
||||
let (registers, changes) = phone.sync(&[&forged], 20);
|
||||
assert!(changes.shares_added.is_empty());
|
||||
assert!(!registers.contains_key("share/share-OTHER"));
|
||||
}
|
||||
}
|
||||
@@ -355,19 +355,19 @@ impl AccountFolder {
|
||||
}
|
||||
|
||||
/// The secrets for one share, as held in the credential store.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ShareSecrets {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct ShareSecrets {
|
||||
/// The full share URL, fragment included.
|
||||
url: String,
|
||||
pub(crate) url: String,
|
||||
/// The custom password, when the link has one.
|
||||
#[serde(default)]
|
||||
custom_password: Option<String>,
|
||||
pub(crate) custom_password: Option<String>,
|
||||
}
|
||||
|
||||
/// The configured shares, and their secrets.
|
||||
pub struct ShareStore {
|
||||
dirs: AppDirs,
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
pub(crate) dirs: AppDirs,
|
||||
pub(crate) secrets: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl ShareStore {
|
||||
@@ -396,9 +396,9 @@ impl ShareStore {
|
||||
|
||||
let mut shares = self.list()?;
|
||||
if shares.iter().any(|share| share.id == id) {
|
||||
return Err(Error::Config(format!(
|
||||
"a share for token {token} is already configured"
|
||||
)));
|
||||
return Err(Error::Config(
|
||||
"that link is already in the library".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let secrets = ShareSecrets {
|
||||
@@ -421,6 +421,80 @@ impl ShareStore {
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// [`Self::add`], but only once the link has been opened with the
|
||||
/// password given. An empty `name` takes the shared folder's own.
|
||||
///
|
||||
/// A link that is mistyped, expired or behind the wrong password used to
|
||||
/// be stored anyway, so the first try failed on the crawl and every retry
|
||||
/// failed on "already configured" — the only way out was to remove a share
|
||||
/// that had never worked. Opening first means a refused link leaves
|
||||
/// nothing behind, and the client it opened is handed back for the crawl.
|
||||
pub async fn add_verified(
|
||||
&self,
|
||||
name: &str,
|
||||
url: &str,
|
||||
custom_password: Option<&str>,
|
||||
) -> Result<(Share, ShareClient)> {
|
||||
let token = token_from_url(url)?;
|
||||
let id = format!("share-{token}");
|
||||
if self.list()?.iter().any(|share| share.id == id) {
|
||||
return Err(Error::Config(
|
||||
"that link is already in the library".to_owned(),
|
||||
));
|
||||
}
|
||||
let custom_password = custom_password.filter(|password| !password.is_empty());
|
||||
let client = match open_link(url, custom_password).await {
|
||||
Ok(client) => client,
|
||||
// Asked only after a refusal: the link's public flags cost a round
|
||||
// trip, and most links open without one.
|
||||
Err(error) if custom_password.is_none() => {
|
||||
let protected = ProtonDrivePublicLinkClient::info(client_configuration(), url)
|
||||
.await
|
||||
.is_ok_and(|info| info.is_custom_password_protected);
|
||||
return Err(if protected {
|
||||
Error::PasswordNeeded
|
||||
} else {
|
||||
error
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
// A name is the viewer's to give, not theirs to invent before they
|
||||
// can see anything: without one, the shared folder's own name.
|
||||
let name = match name.trim() {
|
||||
"" => client
|
||||
.root_node()
|
||||
.await
|
||||
.ok()
|
||||
.map(|root| root.name)
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| "Shared folder".to_owned()),
|
||||
given => given.to_owned(),
|
||||
};
|
||||
let share = self.add(&name, url, custom_password)?;
|
||||
Ok((share, client))
|
||||
}
|
||||
|
||||
/// Record a share another installation has, with the secrets it opens
|
||||
/// with, replacing what this one holds under the same id. See
|
||||
/// [`crate::profile`].
|
||||
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
|
||||
if let Some(secrets) = secrets {
|
||||
self.store_secrets(&share.id, secrets)?;
|
||||
}
|
||||
let mut shares = self.list()?;
|
||||
match shares.iter_mut().find(|existing| existing.id == share.id) {
|
||||
Some(existing) => *existing = share,
|
||||
None => shares.push(share),
|
||||
}
|
||||
write_json(&self.dirs.shares_file(), &shares)
|
||||
}
|
||||
|
||||
/// A share's secrets, for syncing them to another installation.
|
||||
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
|
||||
self.load_secrets(id)
|
||||
}
|
||||
|
||||
/// Record a folder of the signed-in account as a share.
|
||||
///
|
||||
/// Nothing goes in the credential store: the folder opens through the
|
||||
@@ -493,6 +567,25 @@ impl ShareStore {
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Call a share something else. Only the name changes: the id, which the
|
||||
/// catalog, watch state and offline files are keyed by, stays. Sync sees
|
||||
/// the new name as a change to the share's register, like any other.
|
||||
pub fn rename(&self, id: &str, name: &str) -> Result<Share> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(Error::Config("a share needs a name".to_owned()));
|
||||
}
|
||||
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}")))?;
|
||||
share.name = name.to_owned();
|
||||
let renamed = share.clone();
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
Ok(renamed)
|
||||
}
|
||||
|
||||
/// Forget a share and its secrets.
|
||||
///
|
||||
/// The config entry goes first here, for the mirror-image reason: if the
|
||||
@@ -526,17 +619,7 @@ impl ShareStore {
|
||||
});
|
||||
}
|
||||
let secrets = self.load_secrets(&share.id)?;
|
||||
let client = ProtonDrivePublicLinkClient::open(
|
||||
client_configuration(),
|
||||
&secrets.url,
|
||||
secrets.custom_password.as_deref(),
|
||||
)
|
||||
.await?
|
||||
// The SDK default is sized for a background sync daemon. A player that
|
||||
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
||||
// ceiling, which is unremarkable for a desktop app.
|
||||
.with_max_inflight_blocks(48);
|
||||
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||
open_link(&secrets.url, secrets.custom_password.as_deref()).await
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
@@ -556,9 +639,89 @@ impl ShareStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a public link as a visitor.
|
||||
async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> {
|
||||
let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password)
|
||||
.await?
|
||||
// The SDK default is sized for a background sync daemon. A player that
|
||||
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
||||
// ceiling, which is unremarkable for a desktop app.
|
||||
.with_max_inflight_blocks(48);
|
||||
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||
}
|
||||
|
||||
/// Several opened shares, presented as one library.
|
||||
pub struct SharedLibrary {
|
||||
clients: BTreeMap<String, ShareClient>,
|
||||
sessions: SessionAges,
|
||||
}
|
||||
|
||||
/// How long a visitor session is assumed good for without a refresh.
|
||||
///
|
||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||
/// failure that the viewer can only clear by restarting or recrawling.
|
||||
pub const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// When each share's visitor session was last known good.
|
||||
///
|
||||
/// Sessions are short-lived and an app routinely sits open, or in the
|
||||
/// background, for hours between one episode and the next, so age is a reason
|
||||
/// to reauthenticate before opening anything. A share with no entry is stale.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionAges {
|
||||
refreshed: parking_lot::Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl SessionAges {
|
||||
/// Record that `share_id`'s session was good just now.
|
||||
pub fn mark(&self, share_id: &str) {
|
||||
self.refreshed
|
||||
.lock()
|
||||
.insert(share_id.to_owned(), std::time::Instant::now());
|
||||
}
|
||||
|
||||
fn is_stale(&self, share_id: &str) -> bool {
|
||||
self.refreshed
|
||||
.lock()
|
||||
.get(share_id)
|
||||
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR)
|
||||
}
|
||||
|
||||
/// Run `operation`, reauthenticating around it.
|
||||
///
|
||||
/// Two guards, because there are two ways to lose a visitor session. Age is
|
||||
/// the common one, and a timestamp catches it before the request. Anything
|
||||
/// else — a session dropped early, a machine that slept through a clock
|
||||
/// change — only shows up as a failure, so a fresh-looking session gets one
|
||||
/// retry behind a refresh. `refresh` is best-effort: when it fails the
|
||||
/// operation reports the real problem, which is more useful than a refresh
|
||||
/// error standing in for it.
|
||||
pub async fn run<T, E, R, RF, O, OF>(
|
||||
&self,
|
||||
share_id: &str,
|
||||
refresh: R,
|
||||
operation: O,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
R: Fn() -> RF,
|
||||
RF: std::future::Future<Output = bool>,
|
||||
O: Fn() -> OF,
|
||||
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||
{
|
||||
let stale = self.is_stale(share_id);
|
||||
if stale && refresh().await {
|
||||
self.mark(share_id);
|
||||
}
|
||||
match operation().await {
|
||||
Err(_) if !stale => {
|
||||
if refresh().await {
|
||||
self.mark(share_id);
|
||||
}
|
||||
operation().await
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One opened share, whichever kind it is.
|
||||
@@ -680,6 +843,9 @@ impl SharedLibrary {
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
let mut clients = BTreeMap::new();
|
||||
let mut failures = Vec::new();
|
||||
// A reused client's session is of unknown age, so it is left stale; one
|
||||
// opened here has just completed its handshake.
|
||||
let sessions = SessionAges::default();
|
||||
|
||||
for share in store.list()? {
|
||||
if let Some(client) = reusable.remove(&share.id) {
|
||||
@@ -688,13 +854,14 @@ impl SharedLibrary {
|
||||
}
|
||||
match store.open(&share, account).await {
|
||||
Ok(client) => {
|
||||
sessions.mark(&share.id);
|
||||
clients.insert(share.id.clone(), client);
|
||||
}
|
||||
Err(e) => failures.push((share, e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok((Self { clients }, failures))
|
||||
Ok((Self { clients, sessions }, failures))
|
||||
}
|
||||
|
||||
/// The opened clients, minus `dropping`, ready to hand to
|
||||
@@ -735,9 +902,37 @@ impl SharedLibrary {
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||
client.refresh_session().await?;
|
||||
self.sessions.mark(share_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `operation` against one share, refreshing its visitor session first
|
||||
/// when it is old and once more if the operation fails. See
|
||||
/// [`SessionAges::run`]. Every open of something on a share goes through
|
||||
/// here, so an app left running for hours does not need restarting to play.
|
||||
pub async fn reauthenticating<T, E, O, OF>(
|
||||
&self,
|
||||
share_id: &str,
|
||||
operation: O,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
O: Fn() -> OF,
|
||||
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||
{
|
||||
self.sessions
|
||||
.run(
|
||||
share_id,
|
||||
|| async {
|
||||
match self.client(share_id) {
|
||||
Some(client) => client.refresh_session().await.is_ok(),
|
||||
None => false,
|
||||
}
|
||||
},
|
||||
operation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Walk one share's whole subtree, level by level, yielding every node.
|
||||
///
|
||||
/// There is no recursion helper on the visitor path — the authenticated
|
||||
@@ -879,6 +1074,83 @@ fn token_from_url(url: &str) -> Result<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// An operation that fails its first `failures` calls, counting every call.
|
||||
fn flaky(
|
||||
failures: usize,
|
||||
calls: &AtomicUsize,
|
||||
) -> impl Fn() -> std::future::Ready<std::result::Result<(), ()>> {
|
||||
move || {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(if call < failures { Err(()) } else { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_share_never_refreshed_is_refreshed_before_the_operation() {
|
||||
let ages = SessionAges::default();
|
||||
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let refresh = || {
|
||||
refreshes.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(true)
|
||||
};
|
||||
ages.run("s", refresh, flaky(0, &calls)).await.unwrap();
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fresh_session_is_not_refreshed_when_the_operation_succeeds() {
|
||||
let ages = SessionAges::default();
|
||||
ages.mark("s");
|
||||
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let refresh = || {
|
||||
refreshes.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(true)
|
||||
};
|
||||
ages.run("s", refresh, flaky(0, &calls)).await.unwrap();
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// A session Proton dropped early looks fresh by age, and only the failure
|
||||
/// gives it away.
|
||||
#[tokio::test]
|
||||
async fn a_fresh_session_that_fails_is_refreshed_once_and_retried() {
|
||||
let ages = SessionAges::default();
|
||||
ages.mark("s");
|
||||
let (refreshes, calls) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let refresh = || {
|
||||
refreshes.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(true)
|
||||
};
|
||||
ages.run("s", refresh, flaky(1, &calls)).await.unwrap();
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// A refresh just happened, so a second one would learn nothing: the
|
||||
/// failure is the operation's own and is reported as it is.
|
||||
#[tokio::test]
|
||||
async fn a_stale_session_that_still_fails_after_its_refresh_is_not_retried() {
|
||||
let ages = SessionAges::default();
|
||||
let calls = AtomicUsize::new(0);
|
||||
let result = ages
|
||||
.run("s", || std::future::ready(true), flaky(5, &calls))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_refresh_leaves_the_session_stale() {
|
||||
let ages = SessionAges::default();
|
||||
let calls = AtomicUsize::new(0);
|
||||
ages.run("s", || std::future::ready(false), flaky(0, &calls))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ages.is_stale("s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_url_yields_its_token() {
|
||||
@@ -1004,6 +1276,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_share_keeps_its_id_and_refuses_an_empty_name() {
|
||||
let (store, _) = store_in("rename");
|
||||
let added = store
|
||||
.add(
|
||||
"Library",
|
||||
"https://drive.proton.me/urls/ABC123#s3cr3t",
|
||||
None,
|
||||
)
|
||||
.expect("add");
|
||||
|
||||
let renamed = store.rename(&added.id, " Anime ").expect("rename");
|
||||
assert_eq!(renamed.id, added.id);
|
||||
assert_eq!(renamed.name, "Anime");
|
||||
assert_eq!(store.list().expect("list")[0].name, "Anime");
|
||||
assert!(store.rename(&added.id, " ").is_err());
|
||||
assert!(store.rename("share-missing", "Anime").is_err());
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Watch history kept in the viewer's own Drive, so every device resumes where
|
||||
//! another left off.
|
||||
//! another left off — and, in the same file, the shares, settings and
|
||||
//! hand-picked matches that make the library look the same on every device
|
||||
//! (see [`crate::profile`]).
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
@@ -17,7 +19,8 @@
|
||||
//! nothing is overwritten and there is no write conflict to resolve.
|
||||
//!
|
||||
//! Each file carries the installation's whole watch table, and merging is per
|
||||
//! episode: the newer `updated_at` wins. That is idempotent and order-free, so
|
||||
//! episode: the newer `updated_at` wins. The profile's registers merge the same
|
||||
//! way, per key. That is idempotent and order-free, so
|
||||
//! it does not matter how many devices there are, which synced first, or that
|
||||
//! a device writes back entries it learned from another. A device whose clock
|
||||
//! runs ahead wins ties it should not, which is the price of not having a
|
||||
@@ -25,11 +28,11 @@
|
||||
//!
|
||||
//! The keys travel: `share-{token}` is derived from the link and a folder's
|
||||
//! `drive-…` id from its node, so the same library added on two devices has
|
||||
//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the
|
||||
//! link token decrypts nothing without the fragment — and the file is
|
||||
//! end-to-end encrypted by Drive regardless.
|
||||
//! the same `(share_id, link_id)` on both. The watch entries carry nothing
|
||||
//! secret; the profile's share registers do carry each link and its password,
|
||||
//! which is why they live only here, end-to-end encrypted by Drive.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
|
||||
@@ -40,6 +43,8 @@ use crate::account::Account;
|
||||
use crate::catalog::{Catalog, WatchState};
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::{Profile, ProfileChanges, Registers};
|
||||
use crate::shares::ShareStore;
|
||||
|
||||
/// The Drive device watch history is kept on.
|
||||
pub const DEVICE_NAME: &str = "proton-stream";
|
||||
@@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream";
|
||||
const FOLDER: &str = "watch-history";
|
||||
|
||||
/// Bumped only for a change an older build would misread. A file of a newer
|
||||
/// version is skipped rather than half-understood.
|
||||
/// version is skipped rather than half-understood. The profile's registers
|
||||
/// were added without a bump: a build that predates them ignores the field.
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// One installation's watch history, as stored in Drive.
|
||||
@@ -57,6 +63,15 @@ struct HistoryFile {
|
||||
version: u32,
|
||||
installation: String,
|
||||
entries: Vec<HistoryEntry>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
registers: Registers,
|
||||
}
|
||||
|
||||
/// What one history file holds.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
struct Contents {
|
||||
entries: Vec<HistoryEntry>,
|
||||
registers: Registers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -112,7 +127,7 @@ struct Identity {
|
||||
}
|
||||
|
||||
/// What one [`WatchSync::sync`] did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SyncReport {
|
||||
/// Episodes whose position another device had newer.
|
||||
pub applied: usize,
|
||||
@@ -123,6 +138,8 @@ pub struct SyncReport {
|
||||
/// Files of installations gone quiet that were moved to the trash, their
|
||||
/// history now carried in this installation's own file.
|
||||
pub retired: usize,
|
||||
/// What other devices' shares and settings changed here.
|
||||
pub profile: ProfileChanges,
|
||||
}
|
||||
|
||||
/// How long an installation's file may go unwritten before another
|
||||
@@ -243,10 +260,10 @@ impl HistoryDrive for ProtonDriveClient {
|
||||
}
|
||||
|
||||
/// A history file as last read, by the revision it was read at. `None`
|
||||
/// entries for one this build could not read.
|
||||
/// contents for one this build could not read.
|
||||
struct Seen {
|
||||
revision: Option<String>,
|
||||
entries: Option<Vec<HistoryEntry>>,
|
||||
contents: Option<Contents>,
|
||||
}
|
||||
|
||||
/// Syncs the catalog's watch table with the account's Drive.
|
||||
@@ -257,8 +274,9 @@ pub struct WatchSync {
|
||||
seen: Mutex<HashMap<String, Seen>>,
|
||||
/// What this installation's own file holds, once known: what was read
|
||||
/// back at the first sync, then what was last written. Compared against
|
||||
/// the catalog so an unchanged history is not uploaded as a new revision.
|
||||
own: Mutex<Option<Vec<HistoryEntry>>>,
|
||||
/// the catalog and the profile so an unchanged history is not uploaded as
|
||||
/// a new revision.
|
||||
own: Mutex<Option<Contents>>,
|
||||
/// The history folders, resolved once per account. Usually one; see
|
||||
/// [`HistoryDrive::history_folders`].
|
||||
folders: Mutex<Option<Vec<NodeUid>>>,
|
||||
@@ -303,25 +321,34 @@ impl WatchSync {
|
||||
*self.folders.lock() = None;
|
||||
}
|
||||
|
||||
/// Pull every other installation's history into `catalog`, write this
|
||||
/// one's if it changed, then retire the files of installations gone quiet.
|
||||
/// Pull every other installation's history, shares and settings in, write
|
||||
/// this one's if it changed, then retire the files of installations gone
|
||||
/// quiet.
|
||||
///
|
||||
/// The catalog is locked only around its own reads and writes, never
|
||||
/// across a request, so playback can keep saving its position while this
|
||||
/// runs.
|
||||
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
|
||||
/// runs. Shares that arrive are configured but not opened or crawled; see
|
||||
/// [`SyncReport::profile`] for what the front end has to do about them.
|
||||
pub async fn sync(
|
||||
&self,
|
||||
account: &Account,
|
||||
catalog: &Mutex<Catalog>,
|
||||
store: &ShareStore,
|
||||
) -> Result<SyncReport> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_secs() as i64);
|
||||
self.sync_with(account.drive(), catalog, now).await
|
||||
let profile = Profile { store, catalog };
|
||||
self.sync_with(account.drive(), &profile, now).await
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_with<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
catalog: &Mutex<Catalog>,
|
||||
profile: &Profile<'_>,
|
||||
now: i64,
|
||||
) -> Result<SyncReport> {
|
||||
let catalog = profile.catalog;
|
||||
let _running = self.running.lock().await;
|
||||
let folders = self.folders(drive).await?;
|
||||
let own_name = format!("{}.json", self.installation);
|
||||
@@ -339,12 +366,14 @@ impl WatchSync {
|
||||
}
|
||||
|
||||
let mut remote = Vec::new();
|
||||
let mut registers = Vec::new();
|
||||
// Files whose every entry has been read, and so can be retired once
|
||||
// this installation's file carries them.
|
||||
let mut readable = Vec::new();
|
||||
for file in &others {
|
||||
if let Some(entries) = self.read(drive, file).await {
|
||||
remote.extend(entries);
|
||||
if let Some(contents) = self.read(drive, file).await {
|
||||
remote.extend(contents.entries);
|
||||
registers.push(contents.registers);
|
||||
readable.push(file);
|
||||
}
|
||||
}
|
||||
@@ -355,19 +384,22 @@ impl WatchSync {
|
||||
// less than it holds, and read again next time.
|
||||
let mut own_unreadable = false;
|
||||
if self.own.lock().is_none() {
|
||||
let entries = match &own_file {
|
||||
let contents = match &own_file {
|
||||
Some(file) => self.read(drive, file).await,
|
||||
None => Some(Vec::new()),
|
||||
None => Some(Contents::default()),
|
||||
};
|
||||
match entries {
|
||||
Some(entries) => {
|
||||
remote.extend(entries.iter().cloned());
|
||||
*self.own.lock() = Some(entries);
|
||||
match contents {
|
||||
Some(contents) => {
|
||||
remote.extend(contents.entries.iter().cloned());
|
||||
registers.push(contents.registers.clone());
|
||||
*self.own.lock() = Some(contents);
|
||||
}
|
||||
None => own_unreadable = true,
|
||||
}
|
||||
}
|
||||
|
||||
let (registers, profile_changes) = profile.sync(®isters, now)?;
|
||||
|
||||
let incoming: Vec<(String, String, WatchState)> = remote
|
||||
.iter()
|
||||
.filter(|entry| entry.is_sane())
|
||||
@@ -388,15 +420,17 @@ impl WatchSync {
|
||||
// A file that is missing is written even when what it would hold has
|
||||
// not changed: another installation retired it, or it was deleted by
|
||||
// hand, and either way the history it carried lives only here now.
|
||||
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
|
||||
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
|
||||
{
|
||||
let contents = Contents { entries, registers };
|
||||
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&contents);
|
||||
let empty = contents.entries.is_empty() && contents.registers.is_empty();
|
||||
let uploaded = if own_unreadable || unchanged || (empty && own_file.is_none()) {
|
||||
false
|
||||
} else {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION,
|
||||
installation: self.installation.clone(),
|
||||
entries: entries.clone(),
|
||||
entries: contents.entries.clone(),
|
||||
registers: contents.registers.clone(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file)
|
||||
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
|
||||
@@ -404,7 +438,7 @@ impl WatchSync {
|
||||
Some(file) => drive.replace(&file.uid, &bytes).await?,
|
||||
None => drive.create(&folders[0], &own_name, &bytes).await?,
|
||||
}
|
||||
*self.own.lock() = Some(entries);
|
||||
*self.own.lock() = Some(contents);
|
||||
true
|
||||
};
|
||||
|
||||
@@ -435,34 +469,31 @@ impl WatchSync {
|
||||
uploaded,
|
||||
devices: others.len(),
|
||||
retired: retired.len(),
|
||||
profile: profile_changes,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entries of one history file, from the cache when its revision has
|
||||
/// The contents of one history file, from the cache when its revision has
|
||||
/// not moved, or `None` when it cannot be read.
|
||||
///
|
||||
/// A file that will not download or parse is logged and skipped: it is
|
||||
/// another device's, and one bad file must not stop the rest syncing.
|
||||
async fn read<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
file: &RemoteFile,
|
||||
) -> Option<Vec<HistoryEntry>> {
|
||||
async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> {
|
||||
let key = file.uid.link_id.as_str().to_owned();
|
||||
if let Some(seen) = self.seen.lock().get(&key)
|
||||
&& file.revision.is_some()
|
||||
&& seen.revision == file.revision
|
||||
{
|
||||
return seen.entries.clone();
|
||||
return seen.contents.clone();
|
||||
}
|
||||
let uid = &file.uid;
|
||||
let entries = match drive.download(uid).await {
|
||||
let contents = match drive.download(uid).await {
|
||||
Ok(bytes) => {
|
||||
let entries = parse(&bytes);
|
||||
if entries.is_none() {
|
||||
let contents = parse(&bytes);
|
||||
if contents.is_none() {
|
||||
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
||||
}
|
||||
entries
|
||||
contents
|
||||
}
|
||||
Err(error) => {
|
||||
// Not cached: a download that failed is tried again next time.
|
||||
@@ -474,10 +505,10 @@ impl WatchSync {
|
||||
key,
|
||||
Seen {
|
||||
revision: file.revision.clone(),
|
||||
entries: entries.clone(),
|
||||
contents: contents.clone(),
|
||||
},
|
||||
);
|
||||
entries
|
||||
contents
|
||||
}
|
||||
|
||||
/// The history folders, resolved once per account.
|
||||
@@ -491,10 +522,13 @@ impl WatchSync {
|
||||
}
|
||||
}
|
||||
|
||||
/// A history file's entries, or `None` for one this build cannot read.
|
||||
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
|
||||
/// A history file's contents, or `None` for one this build cannot read.
|
||||
fn parse(bytes: &[u8]) -> Option<Contents> {
|
||||
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
|
||||
(file.version <= FORMAT_VERSION).then_some(file.entries)
|
||||
(file.version <= FORMAT_VERSION).then_some(Contents {
|
||||
entries: file.entries,
|
||||
registers: file.registers,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind of device to register, which is only the icon the web app draws.
|
||||
@@ -518,6 +552,7 @@ fn new_installation_id() -> Result<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::tests::Device;
|
||||
|
||||
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
|
||||
HistoryEntry {
|
||||
@@ -655,11 +690,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// An installation: its sync and its catalog.
|
||||
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
|
||||
(
|
||||
WatchSync::with_installation(id.to_owned()),
|
||||
Mutex::new(Catalog::in_memory().unwrap()),
|
||||
)
|
||||
fn device(id: &str) -> (WatchSync, Device) {
|
||||
(WatchSync::with_installation(id.to_owned()), Device::new(id))
|
||||
}
|
||||
|
||||
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
|
||||
@@ -684,11 +716,14 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let first = laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first.uploaded);
|
||||
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
|
||||
let second = phone
|
||||
.sync_with(&drive, &phone_catalog.profile(), 110)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second.applied, 1);
|
||||
assert_eq!(second.devices, 1);
|
||||
@@ -714,12 +749,15 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
phone
|
||||
.sync_with(&drive, &phone_catalog.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -740,8 +778,14 @@ mod tests {
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
let again = laptop
|
||||
.sync_with(&drive, &catalog.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!again.uploaded);
|
||||
assert_eq!(drive.uploads(), 1);
|
||||
@@ -751,7 +795,10 @@ mod tests {
|
||||
async fn nothing_is_uploaded_before_anything_was_watched() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
assert!(drive.names().is_empty());
|
||||
}
|
||||
@@ -764,11 +811,17 @@ mod tests {
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
|
||||
drive.trash(&own).await.unwrap();
|
||||
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(report.uploaded);
|
||||
assert_eq!(drive.names(), ["laptop.json"]);
|
||||
@@ -782,11 +835,17 @@ mod tests {
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The catalog was deleted; the config, and with it the id, was not.
|
||||
let (relaunched, empty) = device("laptop");
|
||||
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
|
||||
let report = relaunched
|
||||
.sync_with(&drive, &empty.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.applied, 1);
|
||||
assert!(!report.uploaded);
|
||||
@@ -804,7 +863,10 @@ mod tests {
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (relaunched, other) = device("laptop");
|
||||
other
|
||||
@@ -814,13 +876,19 @@ mod tests {
|
||||
drive
|
||||
.offline
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
|
||||
let report = relaunched
|
||||
.sync_with(&drive, &other.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
|
||||
drive
|
||||
.offline
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
relaunched.sync_with(&drive, &other, 300).await.unwrap();
|
||||
relaunched
|
||||
.sync_with(&drive, &other.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = other.lock().all_watch_states().unwrap();
|
||||
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
|
||||
}
|
||||
@@ -832,6 +900,7 @@ mod tests {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "future".to_owned(),
|
||||
entries: vec![entry("ep1", 60.0, 100)],
|
||||
registers: Registers::new(),
|
||||
};
|
||||
drive.put("future.json", serde_json::to_vec(&future).unwrap());
|
||||
let (laptop, catalog) = device("laptop");
|
||||
@@ -841,7 +910,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
|
||||
.sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -860,21 +929,24 @@ mod tests {
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
old_phone
|
||||
.sync_with(&drive, &old_catalog, 100)
|
||||
.sync_with(&drive, &old_catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let later = 100 + RETIRE_AFTER_SECS;
|
||||
drive.at(later);
|
||||
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
|
||||
let report = phone
|
||||
.sync_with(&drive, &catalog.profile(), later)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.retired, 1);
|
||||
assert_eq!(drive.names(), ["phone.json"]);
|
||||
// Nothing was lost: the newcomer's file carries it now.
|
||||
let (fresh, fresh_catalog) = device("fresh");
|
||||
fresh
|
||||
.sync_with(&drive, &fresh_catalog, later)
|
||||
.sync_with(&drive, &fresh_catalog.profile(), later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -893,13 +965,13 @@ mod tests {
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let report = phone
|
||||
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
|
||||
.sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -925,9 +997,13 @@ mod tests {
|
||||
version: FORMAT_VERSION,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
registers: Registers::new(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file).unwrap();
|
||||
assert_eq!(parse(&bytes), Some(file.entries));
|
||||
assert_eq!(
|
||||
parse(&bytes).map(|contents| contents.entries),
|
||||
Some(file.entries)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -936,6 +1012,7 @@ mod tests {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
registers: Registers::new(),
|
||||
};
|
||||
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
|
||||
assert_eq!(parse(b"not json"), None);
|
||||
|
||||
@@ -52,4 +52,4 @@ pub use player::{
|
||||
};
|
||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||
pub use render::VideoRenderer;
|
||||
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
||||
pub use tracks::{LANGUAGES, Track, TrackKind, language_name, pick_track};
|
||||
@@ -217,6 +217,9 @@ pub struct Player {
|
||||
/// no stream callback can be running.
|
||||
mpv: Mpv,
|
||||
registry: Arc<StreamRegistry>,
|
||||
/// [`Self::play_from`] set mpv's `start`, which holds for every file
|
||||
/// after until it is put back.
|
||||
start_set: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -298,7 +301,11 @@ impl Player {
|
||||
|
||||
protocol::register(&mpv, ®istry)?;
|
||||
|
||||
let player = Self { mpv, registry };
|
||||
let player = Self {
|
||||
mpv,
|
||||
registry,
|
||||
start_set: std::sync::atomic::AtomicBool::new(false),
|
||||
};
|
||||
player.observe_playback_properties()?;
|
||||
Ok(player)
|
||||
}
|
||||
@@ -325,11 +332,42 @@ impl Player {
|
||||
/// The returned handle keeps the URL resolvable; drop it once the file is
|
||||
/// done with. Dropping it does not interrupt playback already underway.
|
||||
pub fn play(&self, stream: VideoStream) -> Result<StreamHandle> {
|
||||
self.clear_start();
|
||||
self.load(stream)
|
||||
}
|
||||
|
||||
fn load(&self, stream: VideoStream) -> Result<StreamHandle> {
|
||||
let handle = self.registry.publish(stream);
|
||||
self.mpv.command("loadfile", &[&handle.url()])?;
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Start playing a stream `seconds` in.
|
||||
///
|
||||
/// Through mpv's `start` option rather than a seek once the file has
|
||||
/// loaded: by then the demuxer has begun reading ahead from the
|
||||
/// beginning, which on a stream is blocks fetched only to be thrown away,
|
||||
/// queued in front of the ones the seek wants.
|
||||
pub fn play_from(&self, stream: VideoStream, seconds: f64) -> Result<StreamHandle> {
|
||||
self.mpv.set_property("start", format!("{seconds}"))?;
|
||||
self.start_set
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
self.load(stream)
|
||||
}
|
||||
|
||||
/// Put `start` back once the file it was for has loaded, so the next
|
||||
/// one starts at its beginning. [`Self::play`] puts it back too, for a
|
||||
/// file that never loaded.
|
||||
fn clear_start(&self) {
|
||||
if self
|
||||
.start_set
|
||||
.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
&& let Err(error) = self.mpv.set_property("start", "none")
|
||||
{
|
||||
tracing::warn!(%error, "could not clear mpv's start");
|
||||
}
|
||||
}
|
||||
|
||||
/// Play anything else mpv can open: a local path, or one of its own
|
||||
/// pseudo-protocols.
|
||||
///
|
||||
@@ -351,7 +389,15 @@ impl Player {
|
||||
/// property that briefly has no value is normal, not fatal.
|
||||
pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> {
|
||||
match self.mpv.wait_event(timeout)? {
|
||||
Ok(event) => Some(translate(event)),
|
||||
Ok(event) => {
|
||||
let event = translate(event);
|
||||
// Not on an end of file: loading one file ends the one it
|
||||
// replaces, before the new one has read `start`.
|
||||
if matches!(event, PlayerEvent::FileLoaded) {
|
||||
self.clear_start();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "mpv event carried an error");
|
||||
Some(PlayerEvent::Other)
|
||||
|
||||
@@ -190,6 +190,16 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The languages offered by name where a preference is chosen ahead of any
|
||||
/// file, as the three-letter tag a Matroska file carries. The same list
|
||||
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
|
||||
/// and three-letter forms as one.
|
||||
pub const LANGUAGES: [&str; 29] = [
|
||||
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
|
||||
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
|
||||
"gre", "ind", "may",
|
||||
];
|
||||
|
||||
/// A readable name for a language tag, falling back to the tag itself.
|
||||
///
|
||||
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
||||
|
||||
@@ -223,20 +223,48 @@ impl VideoStream {
|
||||
/// seek, and a seek cancels read-ahead — which here would cancel the very
|
||||
/// fetch it was meant to have started.
|
||||
pub fn warm_edges(&self) {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
let count = self.inner.map.len();
|
||||
let edges: &[usize] = match count {
|
||||
0 => &[],
|
||||
1 => &[0],
|
||||
_ => &[0, count - 1],
|
||||
};
|
||||
for &index in edges {
|
||||
self.warm(edges);
|
||||
}
|
||||
|
||||
/// [`Self::warm_edges`], and the block `fraction` of the way through the
|
||||
/// file: where a resumed episode seeks once its index is read.
|
||||
///
|
||||
/// An estimate, from the bytes being spread evenly over the running time.
|
||||
/// A video's bitrate is not even, so it can land a block off the one the
|
||||
/// seek wants. Then it is one block fetched for nothing, against a round
|
||||
/// trip saved, after the two the edges cost, when it lands.
|
||||
pub fn warm_resume(&self, fraction: f64) {
|
||||
self.warm_edges();
|
||||
if !(0.0..1.0).contains(&fraction) {
|
||||
return;
|
||||
}
|
||||
let offset = (self.inner.map.size() as f64 * fraction) as u64;
|
||||
let count = self.inner.map.len();
|
||||
if let Some(index) = self.inner.map.block_at(offset)
|
||||
&& index != 0
|
||||
&& index + 1 != count
|
||||
{
|
||||
self.warm(&[index]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch these blocks in the background. Detached: see
|
||||
/// [`Self::warm_edges`] for why not read-ahead.
|
||||
fn warm(&self, indices: &[usize]) {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
for &index in indices {
|
||||
let inner = Arc::clone(&self.inner);
|
||||
runtime.spawn(async move {
|
||||
if let Err(error) = block_at(inner, index).await {
|
||||
tracing::debug!(index, %error, "warming an edge block failed");
|
||||
tracing::debug!(index, %error, "warming a block failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1037,6 +1065,21 @@ mod tests {
|
||||
assert_eq!(fake.fetches(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warming_a_resume_fetches_the_block_that_far_in_besides_the_edges() {
|
||||
let fake = Fake::new(&[1000; 10]);
|
||||
let stream = stream_over(Arc::clone(&fake), 0);
|
||||
|
||||
stream.warm_resume(0.45);
|
||||
assert!(
|
||||
eventually(|| {
|
||||
fake.fetches_of(0) == 1 && fake.fetches_of(4) == 1 && fake.fetches_of(9) == 1
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(fake.fetches(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
|
||||
let disk = disk_in("fill").await;
|
||||
|
||||
@@ -55,15 +55,15 @@ installs the signed minified APK and runs instrumentation against it, which is
|
||||
the only path that exercises R8 — the step that has broken this app five distinct
|
||||
ways that a debug build cannot reproduce. Without the `ANDROID_RELEASE_*`
|
||||
variables below, drop `--release` and the debug APK is used instead. The matrix — phone and tablet layouts, rotation,
|
||||
process recreation, playback and background controls, Picture-in-Picture,
|
||||
process recreation, playback and background controls,
|
||||
download cancellation/resume, and an offline launch with networking disabled —
|
||||
is `scripts/android-acceptance.sh`; `--list` prints every case and what it
|
||||
needs, and `PSTR_ACCEPTANCE_ONLY=name` runs one. Five cases need a real share,
|
||||
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
|
||||
reach the device as instrumentation arguments and are stored nowhere. Without
|
||||
them those cases report `skip`. `picture-in-picture` is still `pending`, so the
|
||||
run tells you the matrix is incomplete — but no longer because of B40, which is
|
||||
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer
|
||||
them those cases report `skip`. There is no Picture-in-Picture: it was removed
|
||||
after a PiP window that never sized its video right proved less use than
|
||||
background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer
|
||||
detail, including why lint runs against a baseline.
|
||||
|
||||
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
|
||||
@@ -110,6 +110,6 @@ ABIs, plus a source archive and revision record, under
|
||||
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
|
||||
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
|
||||
memory. `PlaybackService` owns the mpv core for background audio and media
|
||||
controls, while the activity supplies the current `Surface` and enters PiP.
|
||||
controls, while the activity supplies the current `Surface`.
|
||||
Do not publish an Android binary until this exact pipeline is verified on real
|
||||
arm64 hardware and an x86_64 emulator.
|
||||
@@ -118,6 +118,18 @@ an edge case.
|
||||
including trashing the file of an installation quiet for 90 days once its
|
||||
history is merged into this one's. Folders of the account added as shares
|
||||
are only ever read.
|
||||
- The same file carries the rest of what makes every device look alike
|
||||
(`pstr-core::profile`): the shares, with each link and custom password so
|
||||
another device can open them; the playback, appearance and metadata settings
|
||||
except volume and mute; the TMDB key; hand-picked matches; per-show track
|
||||
choices. Each is a register merged per key, newer wins, a removed share being
|
||||
a register with no value. `sync-state.json` holds the registers as last
|
||||
synced, and a sync stamps whatever differs from it, so nothing that writes a
|
||||
setting has to know about sync. A first sync stamps what it finds at zero, so
|
||||
a fresh install's defaults never outrank the library everyone else agreed on.
|
||||
Automatic matches are not synced: every device with enrichment on finds them
|
||||
again. The front end crawls what arrived (`uncrawled_shares` covers a sync
|
||||
that ran in the background) and drops what was removed with its files.
|
||||
- A CAPTCHA in front of a sign-in is shown in a webview, because the page
|
||||
reports success by posting a message to its host. On the desktop that is a
|
||||
child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux,
|
||||
@@ -139,7 +151,8 @@ an edge case.
|
||||
| Watch state | same DB, own table | **No** — deliberately survives a recrawl and removing the share; with an account, mirrored to its Drive |
|
||||
| Proton session | OS credential store, `proton-account` | No — sign in again |
|
||||
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
|
||||
| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
|
||||
| Sync state | config dir, `sync-state.json` | Yes — a missing one is a first sync, which takes everything other devices have |
|
||||
| Watch history and profile in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode and per register, newer wins. Holds share links and passwords, end-to-end encrypted by Drive. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
|
||||
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
|
||||
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
|
||||
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
|
||||
|
||||
@@ -20,8 +20,7 @@ and B41 states which of its items are done.
|
||||
|
||||
Individually small, grouped so they are not lost. The items marked **Landed**
|
||||
came with Phases 4 and 5; the rest are still open, which is why this entry is.
|
||||
What remains is one item — the asynchronous track-list read-back — plus the
|
||||
`SettingsStore` construction on the main thread.
|
||||
What remains is one item: the asynchronous track-list read-back.
|
||||
|
||||
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an
|
||||
asynchronous property set, so the selection tick lags by up to a second;
|
||||
@@ -39,8 +38,10 @@ What remains is one item — the asynchronous track-list read-back — plus the
|
||||
unreachable. The constructor is private behind `NativeMpvHost.createOrNull()`,
|
||||
the service holds a nullable host, and the binder hands back a nullable one —
|
||||
which is what `PlayerScreen` was already written for.
|
||||
- `PlaybackService.kt:43` constructs `SettingsStore` on the main thread inside a
|
||||
hot callback; the first SharedPreferences access is disk I/O.
|
||||
- **Landed.** `PlaybackService.kt:43` constructs `SettingsStore` on the main
|
||||
thread inside a hot callback; the first SharedPreferences access is disk I/O.
|
||||
`ProtonStreamApplication` now loads the file on a background thread at
|
||||
startup, so every later read is from memory.
|
||||
- **Landed.** `MainActivity` set `bound` only in `onServiceConnected`, so an
|
||||
activity destroyed with a bind still pending never unbound. It is now set from
|
||||
the `bindService` return value.
|
||||
@@ -117,15 +118,13 @@ position advances, which can only happen if real decrypted bytes reached the
|
||||
decoder. One live run serves all five cases, because each re-crawl of a real
|
||||
share costs about six minutes and the crawl is not what any of them tests.
|
||||
|
||||
**Still open, and the reason this entry stays open.** `picture-in-picture` is
|
||||
`pending`. It is no longer blocked on the app —
|
||||
[B40](#b40--picture-in-picture-was-entered-but-never-adapted-to) landed, so
|
||||
there is correct behaviour to assert — but the live suite drives the engine and
|
||||
libmpv directly, and PiP is a property of the window, so the case needs an
|
||||
activity-level driver that does not exist yet. Every run still prints that the
|
||||
matrix is incomplete. `process-recreation` has been promoted off its `xfail`
|
||||
now that [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
|
||||
has landed, and has not been re-run on hardware since.
|
||||
**Still open, and the reason this entry stays open.** The matrix has not been
|
||||
run on hardware since the 2026-10 redesign of every screen, and
|
||||
`process-recreation` has not been re-run since
|
||||
[B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
|
||||
landed. The `picture-in-picture` case that was `pending` below went with
|
||||
Picture-in-Picture itself, which was later removed (`docs/ANDROID.md`), so
|
||||
nothing is `pending` any more.
|
||||
|
||||
**Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the
|
||||
signed minified release APK:
|
||||
@@ -1289,6 +1288,11 @@ out rather than leaving them mid-timeout.
|
||||
suppressed. The `picture-in-picture` acceptance case is now implementable — it
|
||||
was `pending` precisely because the assertion would have had to assert this bug.
|
||||
|
||||
**Since removed.** Picture-in-Picture itself was taken out later: a PiP window
|
||||
that never sized its video right proved less use than background audio and the
|
||||
lock-screen transport (`docs/ANDROID.md`). This entry records a fix to code
|
||||
that no longer exists.
|
||||
|
||||
### B45 — the play/pause button did nothing, everywhere
|
||||
|
||||
**Symptom.** Once an episode was open, every transport that can pause it was
|
||||
|
||||
@@ -75,10 +75,15 @@ Each of these is the platform's doing, not a choice to diverge.
|
||||
boundary and is queued again; it resumes from its `.part` file when the app next
|
||||
comes forward. "Download on Wi-Fi only" stops transfers on a cellular or
|
||||
otherwise expensive network rather than holding them back.
|
||||
- **Watch history syncs when the app leaves the screen**, inside the same grace, in
|
||||
- **Sync runs when the app leaves the screen**, inside the same grace, in
|
||||
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
|
||||
- **Background audio off pauses** the episode when the app leaves the screen, where
|
||||
Android stops it.
|
||||
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
|
||||
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
|
||||
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
||||
stand in for the ripple, and a settings page goes back by an edge strip of its
|
||||
own, as a title page does, where Android has the system back.
|
||||
- **Nothing is backed up**, as on Android (`allowBackup="false"`). The share
|
||||
secrets are this-device-only Keychain items, so a restored share list would
|
||||
arrive without them, and a restored `sync.json` would give two devices one sync
|
||||
|
||||
@@ -255,10 +255,9 @@ three dispositions, and the third is the one to read:
|
||||
| `pending` | specified but not implemented. Never passes, and the summary reports the matrix as incomplete until it is gone |
|
||||
| `skip` | needs a share that was not configured |
|
||||
|
||||
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is
|
||||
`pending` against B40 — asserting today's behaviour would encode the bug rather
|
||||
than catch it. **The matrix is not complete, and the runner says so on every
|
||||
run.**
|
||||
Today `process-recreation` is `xfail` against B35 — asserting today's behaviour
|
||||
would encode the bug rather than catch it. The `picture-in-picture` case went
|
||||
with Picture-in-Picture itself, so nothing is `pending`.
|
||||
|
||||
The five live cases need a share, given as environment variables that reach the
|
||||
device as instrumentation arguments and are written nowhere:
|
||||
|
||||
@@ -38,26 +38,43 @@ struct WatchSnapshot: Equatable {
|
||||
final class AppModel {
|
||||
var loading = true
|
||||
var refreshing = false
|
||||
/// A crawl is running, which the banner offers to stop.
|
||||
private(set) var crawling = false
|
||||
private(set) var query = ""
|
||||
var titles: [TitleRecord] = []
|
||||
/// The grid: `titles` ordered, each franchise folded into one tile when
|
||||
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
|
||||
/// when the grid falls back to `titles` as they are.
|
||||
var arrangement: ArrangementRecord?
|
||||
var sort: LibrarySort = .name
|
||||
var grouped = true
|
||||
/// As the viewer last left them.
|
||||
var filter: LibraryFilter = SettingsStore().libraryFilter
|
||||
var sort: LibrarySort = SettingsStore().librarySort
|
||||
var grouped = SettingsStore().libraryGrouped
|
||||
var shares: [ShareRecord] = []
|
||||
var offline: [OfflineRecord] = []
|
||||
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
|
||||
var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
|
||||
/// What the snackbar says.
|
||||
var message: String?
|
||||
/// What is running in the background right now, said in a banner for as
|
||||
/// long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||
/// minute-long crawl looked like a tap that did nothing.
|
||||
private(set) var activity: String?
|
||||
/// What Undo on `message` puts back, when the message offers one.
|
||||
var undo: [WatchSnapshot]?
|
||||
var account = AccountUiState()
|
||||
|
||||
@ObservationIgnored private var searchTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var started = false
|
||||
/// Launch and the first `.active` both ask; one crawl answers.
|
||||
@ObservationIgnored private var catchingUp = false
|
||||
/// Every banner line whose work is still running, oldest first.
|
||||
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
||||
@ObservationIgnored private var nextActivity = 0
|
||||
/// Crawls running now.
|
||||
@ObservationIgnored private var crawlsRunning = 0
|
||||
/// Set by `stopCrawl`, so a queue of shares stops rather than going on to the next.
|
||||
@ObservationIgnored private var crawlStopped = false
|
||||
|
||||
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||
/// listing, and no upload.
|
||||
@@ -67,6 +84,7 @@ final class AppModel {
|
||||
if started { return }
|
||||
started = true
|
||||
reload()
|
||||
enteredForeground()
|
||||
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
|
||||
DownloadCoordinator.shared.resumeQueued()
|
||||
Task {
|
||||
@@ -95,6 +113,16 @@ final class AppModel {
|
||||
func arrange(_ sort: LibrarySort, _ grouped: Bool) {
|
||||
self.sort = sort
|
||||
self.grouped = grouped
|
||||
let settings = SettingsStore()
|
||||
settings.librarySort = sort
|
||||
settings.libraryGrouped = grouped
|
||||
Task { await reloadLibrary(query) }
|
||||
}
|
||||
|
||||
/// Narrow the grid to some titles, and keep the choice for the next launch.
|
||||
func setFilter(_ filter: LibraryFilter) {
|
||||
self.filter = filter
|
||||
SettingsStore().libraryFilter = filter
|
||||
Task { await reloadLibrary(query) }
|
||||
}
|
||||
|
||||
@@ -123,49 +151,148 @@ final class AppModel {
|
||||
Task { await crawlNow(shareId) }
|
||||
}
|
||||
|
||||
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
|
||||
private func crawlShares(_ id: String?) async throws {
|
||||
crawlsRunning += 1
|
||||
crawling = true
|
||||
defer {
|
||||
crawlsRunning -= 1
|
||||
crawling = crawlsRunning > 0
|
||||
}
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
}
|
||||
|
||||
/// Stop the crawls running now. Shares already read stay in the library;
|
||||
/// the one being read keeps what it had.
|
||||
func stopCrawl() {
|
||||
crawlStopped = true
|
||||
Task { try? await NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||
/// this returns.
|
||||
func crawlNow(_ shareId: String?) async {
|
||||
refreshing = true
|
||||
message = nil
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
let name = shareId.flatMap { id in shares.first { $0.id == id }?.name }
|
||||
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
||||
message = nil
|
||||
do {
|
||||
try await crawlShares(shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
await reloadNow()
|
||||
// Not awaited: pull to refresh lets go once the crawl is in, and the
|
||||
// banner carries the lookup.
|
||||
Task { await matchAfterCrawl() }
|
||||
}
|
||||
|
||||
/// Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||
/// a new episode its stored guide cannot name. When everything already
|
||||
/// has one the bridge asks nothing, so this is cheap after every crawl.
|
||||
private func matchAfterCrawl() async {
|
||||
if metadataSettings.enabled, metadataSettings.ready {
|
||||
await match(force: false, quiet: true)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Run enrichment again. With `force` every title is looked up afresh,
|
||||
/// including ones that already matched.
|
||||
func matchTitles(force: Bool) {
|
||||
Task {
|
||||
refreshing = true
|
||||
message = nil
|
||||
Task { await match(force: force) }
|
||||
}
|
||||
|
||||
/// With `quiet`, a run nobody asked for says nothing when it found
|
||||
/// nothing to do.
|
||||
private func match(force: Bool, quiet: Bool = false) async {
|
||||
await busy("Looking up titles…") {
|
||||
do {
|
||||
let summary = try await NativeRuntime.engine().matchTitles(force: force)
|
||||
message = Self.describe(summary)
|
||||
let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
|
||||
if !quiet || found {
|
||||
message = Self.describe(summary)
|
||||
}
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Say `what` in the activity banner while `work` runs.
|
||||
///
|
||||
/// The banner shows the newest line still running. Work overlaps — a sync
|
||||
/// starts during a crawl and ends after it — so each line is removed by
|
||||
/// itself rather than by putting back whatever was showing before, which
|
||||
/// once brought back a finished crawl's line and spun it forever.
|
||||
private func busy(_ what: String, _ work: () async -> Void) async {
|
||||
let token = nextActivity
|
||||
nextActivity += 1
|
||||
activities.append((token, what))
|
||||
showActivity()
|
||||
await work()
|
||||
activities.removeAll { $0.token == token }
|
||||
showActivity()
|
||||
}
|
||||
|
||||
private func showActivity() {
|
||||
activity = activities.last?.line
|
||||
refreshing = activity != nil
|
||||
}
|
||||
|
||||
// MARK: - Shares
|
||||
|
||||
func addShare(name: String, url: String, password: String?) {
|
||||
Task {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: nil)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
/// Add a link, and return why it was refused, if it was.
|
||||
///
|
||||
/// The link is opened before anything is stored, so the form can stay up
|
||||
/// with the reason under it and a retry is a retry — not "already in the
|
||||
/// library" for a share that never opened. Only the new share is crawled:
|
||||
/// another share that fails to open must not make this one look broken.
|
||||
func addShare(name: String, url: String, password: String?) async -> String? {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
let share: ShareRecord
|
||||
do {
|
||||
share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password)
|
||||
} catch {
|
||||
return errorMessage(error)
|
||||
}
|
||||
reload()
|
||||
Task { await crawlAdded([share.id], announce: true) }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Crawl shares that are new here — added on this device or by sync —
|
||||
/// then match them when enrichment is on.
|
||||
private func crawlAdded(_ ids: [String], announce: Bool) async {
|
||||
guard !ids.isEmpty else { return }
|
||||
let before = Set(titles.map(\.key))
|
||||
var failed: [String] = []
|
||||
crawlStopped = false
|
||||
for id in ids {
|
||||
if crawlStopped { break }
|
||||
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
||||
await busy("Reading \(name)…") {
|
||||
do {
|
||||
try await crawlShares(id)
|
||||
} catch {
|
||||
failed.append("\(name): \(errorMessage(error))")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
}
|
||||
await reloadNow()
|
||||
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||
// the share's row rather than in their face, and only news is said.
|
||||
if !failed.isEmpty, announce {
|
||||
message = failed.joined(separator: "\n")
|
||||
return
|
||||
}
|
||||
await matchAfterCrawl()
|
||||
if announce {
|
||||
let added = Set(titles.map(\.key)).subtracting(before).count
|
||||
message = added == 1 ? "1 title added" : "\(added) titles added"
|
||||
} else {
|
||||
let files = shares.filter { ids.contains($0.id) }.reduce(UInt64(0)) { $0 + ($1.crawlAdded ?? 0) }
|
||||
if files > 0 { message = files == 1 ? "1 new file" : "\(files) new files" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +300,27 @@ final class AppModel {
|
||||
/// Re-entering the link rewrites the secret and leaves the catalog and the
|
||||
/// offline files alone.
|
||||
func repairShare(id: String, url: String, password: String?) {
|
||||
Task {
|
||||
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||
await busy("Reading \(name) again…") {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
try await crawlShares(id)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
await reloadNow()
|
||||
}
|
||||
await matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a share something else. The library, history and downloads stay.
|
||||
func renameShare(id: String, name: String) {
|
||||
Task {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
_ = try await run { try $0.renameShare(shareId: id, name: name) }
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
@@ -187,21 +330,24 @@ final class AppModel {
|
||||
|
||||
func removeShare(_ id: String) {
|
||||
Task {
|
||||
do {
|
||||
// Wait for the transfers to stop before Rust removes catalog
|
||||
// and files. Rust also rejects any late publication.
|
||||
await DownloadCoordinator.shared.cancelShare(id)
|
||||
let store = DownloadStateStore.shared
|
||||
let engine = try await NativeRuntime.engine()
|
||||
for download in store.records() where download.shareId == id {
|
||||
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||
await busy("Removing \(name)…") {
|
||||
do {
|
||||
// Wait for the transfers to stop before Rust removes catalog
|
||||
// and files. Rust also rejects any late publication.
|
||||
await DownloadCoordinator.shared.cancelShare(id)
|
||||
let store = DownloadStateStore.shared
|
||||
let engine = try await NativeRuntime.engine()
|
||||
for download in store.records() where download.shareId == id {
|
||||
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||
}
|
||||
store.removeShare(id)
|
||||
try await run { try $0.removeShare(shareId: id) }
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
store.removeShare(id)
|
||||
try await run { try $0.removeShare(shareId: id) }
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,29 +416,33 @@ final class AppModel {
|
||||
|
||||
func signOut() {
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().signOut()
|
||||
} catch {
|
||||
reportError(error)
|
||||
await busy("Signing out…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().signOut()
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
account = AccountUiState(state: .signedOut)
|
||||
await reloadNow()
|
||||
}
|
||||
account = AccountUiState(state: .signedOut)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync now, from a tap: says so when it is done.
|
||||
func syncNow() {
|
||||
Task {
|
||||
guard await syncWatchHistory() else { return }
|
||||
var synced = false
|
||||
await busy("Syncing with your other devices…") { synced = await syncWatchHistory() }
|
||||
guard synced else { return }
|
||||
let applied = account.applied
|
||||
message = applied > 0 ? "Watch history synced, \(applied) from other devices" : "Watch history is up to date"
|
||||
message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date"
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||
/// on a timer, and a failure is shown on the account card rather than as a
|
||||
/// snackbar every five minutes of an offline evening. Returns whether it
|
||||
/// synced.
|
||||
/// Pull other devices' positions, shares and settings in and push this
|
||||
/// one's out. Quiet: it runs on a timer, and a failure is shown on the
|
||||
/// account card rather than as a snackbar every five minutes of an offline
|
||||
/// evening. Returns whether it synced.
|
||||
@discardableResult
|
||||
func syncWatchHistory() async -> Bool {
|
||||
guard case .signedIn = account.state else { return false }
|
||||
@@ -301,7 +451,7 @@ final class AppModel {
|
||||
account.syncedAt = Date()
|
||||
account.applied = Int(report.applied)
|
||||
account.syncError = nil
|
||||
if report.applied > 0 { reload() }
|
||||
await takeSynced(report)
|
||||
return true
|
||||
} catch {
|
||||
let state = try? await run { $0.accountState() }
|
||||
@@ -314,6 +464,41 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// What another device changed: its shares crawled or dropped here, its
|
||||
/// settings repainted.
|
||||
private func takeSynced(_ report: SyncRecord) async {
|
||||
for id in report.sharesRemoved {
|
||||
await DownloadCoordinator.shared.cancelShare(id)
|
||||
DownloadStateStore.shared.removeShare(id)
|
||||
}
|
||||
if report.settingsChanged {
|
||||
await AppearanceState.shared.reload()
|
||||
}
|
||||
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
|
||||
await reloadNow()
|
||||
}
|
||||
// A crawl takes longer than the grace iOS gives a backgrounded app.
|
||||
// What is left uncrawled is picked up on the way back.
|
||||
if !report.sharesAdded.isEmpty, UIApplication.shared.applicationState != .background {
|
||||
await crawlAdded(report.sharesAdded, announce: false)
|
||||
message = report.sharesAdded.count == 1 ? "A share arrived from another device" : "\(report.sharesAdded.count) shares arrived from other devices"
|
||||
}
|
||||
}
|
||||
|
||||
/// On launch and back on screen: crawl shares due a look — ones the
|
||||
/// catalog has nothing of, brought by a sync that ran in the background or
|
||||
/// an add whose crawl never finished, and ones not crawled for six hours,
|
||||
/// so new episodes turn up without a pull to refresh.
|
||||
func enteredForeground() {
|
||||
guard !catchingUp else { return }
|
||||
catchingUp = true
|
||||
Task {
|
||||
defer { catchingUp = false }
|
||||
guard let ids = try? await run({ try $0.uncrawledShares() }), !ids.isEmpty else { return }
|
||||
await crawlAdded(ids, announce: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving the app: push where playback stopped, inside the grace iOS
|
||||
/// gives a backgrounded app — what `WatchSyncWorker` does on Android.
|
||||
func syncInBackground() {
|
||||
@@ -333,15 +518,13 @@ final class AppModel {
|
||||
|
||||
func addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
Task {
|
||||
refreshing = true
|
||||
do {
|
||||
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
|
||||
try await NativeRuntime.engine().crawl(shareId: share.id)
|
||||
reload()
|
||||
await crawlAdded([share.id], announce: true)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +534,15 @@ final class AppModel {
|
||||
/// title's positions are what this throws away, and a mis-tap on a tile's
|
||||
/// menu should not cost them.
|
||||
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
|
||||
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
|
||||
markEpisodes(title.playlist, watched, title.displayName)
|
||||
}
|
||||
|
||||
/// Some episodes seen or unseen — a season, or a whole title — with an
|
||||
/// Undo; `what` names them in the message.
|
||||
func markEpisodes(_ episodes: [EpisodeRecord], _ watched: Bool, _ what: String) {
|
||||
let episodes = episodes.filter { $0.watched != watched }
|
||||
showWatched(episodes, watched)
|
||||
changeWatch(episodes, "\(what) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
|
||||
let duration = before?.durationSecs
|
||||
try engine.saveWatchState(
|
||||
shareId: episode.shareId,
|
||||
@@ -388,13 +579,7 @@ final class AppModel {
|
||||
do {
|
||||
try await run { engine in
|
||||
for snapshot in snapshots {
|
||||
try engine.saveWatchState(
|
||||
shareId: snapshot.shareId,
|
||||
linkId: snapshot.linkId,
|
||||
positionSecs: snapshot.state?.positionSecs ?? 0,
|
||||
durationSecs: snapshot.state?.durationSecs,
|
||||
watched: snapshot.state?.watched ?? false
|
||||
)
|
||||
try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -437,24 +622,29 @@ final class AppModel {
|
||||
|
||||
// MARK: - Metadata
|
||||
|
||||
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
Task {
|
||||
do {
|
||||
let engine = try await NativeRuntime.engine()
|
||||
try await run { engine in
|
||||
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
try engine.setMetadataApiKey(provider: provider, key: apiKey)
|
||||
}
|
||||
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||
/// Store the enrichment settings, and return why not, if they were not.
|
||||
///
|
||||
/// The page shows the new settings as soon as they are stored; the match
|
||||
/// run that follows can take minutes and says so in the activity banner.
|
||||
/// Before, the page only changed once that run had finished, so a save
|
||||
/// looked like it had done nothing.
|
||||
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
|
||||
do {
|
||||
metadataSettings = try await run { engine in
|
||||
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
try engine.setMetadataApiKey(provider: provider, key: apiKey)
|
||||
}
|
||||
if enabled {
|
||||
message = Self.describe(try await engine.matchTitles(force: false))
|
||||
}
|
||||
} catch {
|
||||
reportError(error)
|
||||
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||
return try engine.metadataSettings()
|
||||
}
|
||||
reload()
|
||||
} catch {
|
||||
return errorMessage(error)
|
||||
}
|
||||
await reloadNow()
|
||||
if enabled {
|
||||
Task { await match(force: false) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadAfterMetadataChange() { reload() }
|
||||
@@ -476,12 +666,14 @@ final class AppModel {
|
||||
|
||||
func removeOffline(_ file: OfflineRecord) {
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
await busy("Deleting the download…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,14 +696,16 @@ final class AppModel {
|
||||
|
||||
func removeAllOffline() {
|
||||
Task {
|
||||
await DownloadCoordinator.shared.cancelAll()
|
||||
do {
|
||||
try await NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore.shared.clear()
|
||||
} catch {
|
||||
reportError(error)
|
||||
await busy("Deleting every download…") {
|
||||
await DownloadCoordinator.shared.cancelAll()
|
||||
do {
|
||||
try await NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore.shared.clear()
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
await reloadNow()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,69 +749,87 @@ final class AppModel {
|
||||
/// Unwatching rewinds, matching the desktop client. Marking one seen puts
|
||||
/// the position at its duration where one is known, so the progress bar
|
||||
/// agrees with the tick.
|
||||
/// One episode's tick, with the same Undo a season's has.
|
||||
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
|
||||
Task {
|
||||
do {
|
||||
try await run { engine in
|
||||
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
|
||||
try engine.saveWatchState(
|
||||
shareId: episode.shareId,
|
||||
linkId: episode.linkId,
|
||||
positionSecs: watched ? duration ?? 0 : 0,
|
||||
durationSecs: duration,
|
||||
watched: watched
|
||||
)
|
||||
markEpisodes([episode], watched, episode.label)
|
||||
}
|
||||
|
||||
/// Draw a watched mark the moment it is tapped, before the bridge has
|
||||
/// stored it. The reload that shows the stored state is a second away on
|
||||
/// an old iPad, and a tick that does not appear gets tapped again. That
|
||||
/// reload still follows, and puts back whatever was really stored.
|
||||
private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) {
|
||||
let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" })
|
||||
titles = titles.map { title in
|
||||
guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title }
|
||||
var title = title
|
||||
title.seasons = title.seasons.map { season in
|
||||
var season = season
|
||||
season.episodes = season.episodes.map { episode in
|
||||
guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode }
|
||||
var episode = episode
|
||||
episode.watched = watched
|
||||
if !watched {
|
||||
episode.progress = nil
|
||||
episode.resumeAt = nil
|
||||
}
|
||||
return episode
|
||||
}
|
||||
} catch {
|
||||
reportError(error)
|
||||
return season
|
||||
}
|
||||
reload()
|
||||
title.watchedCount = UInt64(title.playlist.filter(\.watched).count)
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
func reload() {
|
||||
Task { await reloadNow() }
|
||||
}
|
||||
|
||||
/// `reload`, for a caller that goes on to read what it loaded.
|
||||
func reloadNow() async {
|
||||
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
|
||||
let filter = filter
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
Task {
|
||||
do {
|
||||
let result = try await run { engine in
|
||||
// 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.
|
||||
_ = try engine.pruneOfflineFiles()
|
||||
return Reloaded(
|
||||
shares: try engine.shares(),
|
||||
titles: try engine.library(search: query),
|
||||
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
|
||||
offline: try engine.offlineFiles(),
|
||||
metadataSettings: try engine.metadataSettings(),
|
||||
storage: try engine.storageUsage()
|
||||
)
|
||||
}
|
||||
loading = false
|
||||
shares = result.shares
|
||||
titles = result.titles
|
||||
arrangement = result.arrangement
|
||||
offline = result.offline
|
||||
metadataSettings = result.metadataSettings
|
||||
storage = result.storage
|
||||
} catch {
|
||||
loading = false
|
||||
message = errorMessage(error)
|
||||
do {
|
||||
let result = try await run { engine in
|
||||
// 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.
|
||||
_ = try engine.pruneOfflineFiles()
|
||||
return Reloaded(
|
||||
shares: try engine.shares(),
|
||||
titles: try engine.library(search: query),
|
||||
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
|
||||
offline: try engine.offlineFiles(),
|
||||
metadataSettings: try engine.metadataSettings(),
|
||||
storage: try engine.storageUsage()
|
||||
)
|
||||
}
|
||||
loading = false
|
||||
shares = result.shares
|
||||
titles = result.titles
|
||||
arrangement = result.arrangement
|
||||
offline = result.offline
|
||||
metadataSettings = result.metadataSettings
|
||||
storage = result.storage
|
||||
} catch {
|
||||
loading = false
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadLibrary(_ query: String) async {
|
||||
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
|
||||
let filter = filter
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
do {
|
||||
let (titles, arrangement) = try await run { engine in
|
||||
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped))
|
||||
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
|
||||
}
|
||||
self.titles = titles
|
||||
self.arrangement = arrangement
|
||||
|
||||
@@ -99,10 +99,17 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Snackbar()
|
||||
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||
VStack(spacing: 8) {
|
||||
ActivityBanner()
|
||||
Snackbar()
|
||||
}
|
||||
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||
.animation(.easeInOut(duration: 0.2), value: model.activity)
|
||||
}
|
||||
.background(scheme.background.ignoresSafeArea())
|
||||
// Covered by the player, but still there to VoiceOver, which
|
||||
// would otherwise walk the library behind the picture.
|
||||
.accessibilityHidden(host.isOpen && !host.minimized)
|
||||
}
|
||||
.overlay {
|
||||
// Over the shell rather than instead of it: the page behind stays
|
||||
@@ -140,6 +147,7 @@ struct RootView: View {
|
||||
model.syncInBackground()
|
||||
case .active:
|
||||
host.enteredForeground()
|
||||
model.enteredForeground()
|
||||
DownloadCoordinator.shared.resumeQueued()
|
||||
default:
|
||||
break
|
||||
@@ -176,9 +184,14 @@ struct RootView: View {
|
||||
selectedTitleKey = title.key
|
||||
})
|
||||
case .shares:
|
||||
SharesView(incomingLink: $incomingLink)
|
||||
// The first share is the library: watch it fill rather than stay
|
||||
// on a form that has done its job.
|
||||
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
|
||||
case .downloads:
|
||||
DownloadsView()
|
||||
DownloadsView(onPlay: play, onOpen: { title in
|
||||
destination = .library
|
||||
selectedTitleKey = title.key
|
||||
})
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
@@ -189,7 +202,16 @@ struct RootView: View {
|
||||
/// tap rather than back and in again.
|
||||
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
|
||||
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
|
||||
let library = LibraryView(onResume: play, onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } })
|
||||
let library = LibraryView(
|
||||
onResume: play,
|
||||
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } },
|
||||
// The form as a pasted link opens it, only with nothing in it.
|
||||
onAddShare: {
|
||||
incomingLink = ""
|
||||
destination = .shares
|
||||
},
|
||||
onOpenSettings: { select(.settings) }
|
||||
)
|
||||
if width >= twoPaneWidth {
|
||||
HStack(spacing: 0) {
|
||||
library.frame(width: width * 0.45)
|
||||
@@ -257,7 +279,7 @@ private struct NavigationBar: View {
|
||||
.padding(.bottom, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.accessibilityLabel(item.label)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
@@ -290,7 +312,7 @@ private struct NavigationRail: View {
|
||||
.frame(width: 80)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.accessibilityLabel(item.label)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
@@ -301,6 +323,38 @@ private struct NavigationRail: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app is busy with, for as long as it is: a spinner and one line.
|
||||
/// Not dismissible and not timed — it goes when the work does.
|
||||
private struct ActivityBanner: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
if let activity = model.activity {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||
Text(activity)
|
||||
.textStyle(.bodyMedium)
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// A crawl of a large share can take minutes, and is the
|
||||
// viewer's to call off; what it has read so far stays.
|
||||
if model.crawling {
|
||||
Button("Stop", action: model.stopCrawl).buttonStyle(.quiet)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||
.padding(.horizontal, 12)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityElement(children: model.crawling ? .contain : .combine)
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's one line of feedback: what just happened, with Undo where it can
|
||||
/// be taken back.
|
||||
private struct Snackbar: View {
|
||||
@@ -318,7 +372,7 @@ private struct Snackbar: View {
|
||||
Button("Undo", action: model.performUndo)
|
||||
.textStyle(.labelLarge)
|
||||
.foregroundStyle(scheme.inversePrimary)
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,39 @@ struct SettingsStore {
|
||||
|
||||
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 }
|
||||
|
||||
/// How the library was last sorted; `.name` until the viewer chooses.
|
||||
var librarySort: LibrarySort {
|
||||
get {
|
||||
let stored = defaults.string(forKey: Key.librarySort)
|
||||
return Self.sorts.first { String(describing: $0) == stored } ?? .name
|
||||
}
|
||||
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) }
|
||||
}
|
||||
|
||||
/// Which titles the library last showed.
|
||||
var libraryFilter: LibraryFilter {
|
||||
get {
|
||||
let stored = defaults.string(forKey: Key.libraryFilter)
|
||||
return Self.filters.first { String(describing: $0) == stored } ?? .all
|
||||
}
|
||||
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.libraryFilter) }
|
||||
}
|
||||
|
||||
/// The library's offer of posters and synopses was turned down.
|
||||
var metadataOfferDismissed: Bool {
|
||||
get { bool(Key.metadataOfferDismissed, default: false) }
|
||||
nonmutating set { defaults.set(newValue, forKey: Key.metadataOfferDismissed) }
|
||||
}
|
||||
|
||||
/// Whether the library folds each franchise into one tile.
|
||||
var libraryGrouped: Bool {
|
||||
get { bool(Key.libraryGrouped, default: true) }
|
||||
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
|
||||
}
|
||||
|
||||
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
|
||||
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
|
||||
|
||||
private func bool(_ key: String, default value: Bool) -> Bool {
|
||||
defaults.object(forKey: key) as? Bool ?? value
|
||||
}
|
||||
@@ -51,5 +84,9 @@ struct SettingsStore {
|
||||
static let backgroundAudio = "background_audio"
|
||||
static let hardwareDecoding = "hardware_decoding"
|
||||
static let cacheBudgetGib = "cache_budget_gib"
|
||||
static let librarySort = "library_sort"
|
||||
static let libraryFilter = "library_filter"
|
||||
static let libraryGrouped = "library_grouped"
|
||||
static let metadataOfferDismissed = "metadata_offer_dismissed"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ final class DownloadCoordinator {
|
||||
/// Every retained download, sorted by label. Re-read after each write.
|
||||
private(set) var records: [RetainedDownload] = []
|
||||
|
||||
/// Why a queued download is not running, when it is not merely a free
|
||||
/// slot: a Wi-Fi-only download on mobile data waits without a word, and
|
||||
/// "Queued" for an hour on the bus reads as stuck.
|
||||
private(set) var queueReason: String?
|
||||
|
||||
/// A download finished or was removed: the catalog has changed.
|
||||
@ObservationIgnored var onFinished: (() -> Void)?
|
||||
|
||||
@@ -125,6 +130,10 @@ final class DownloadCoordinator {
|
||||
/// Re-apply "Wi-Fi only" to what is queued and running: a season queued
|
||||
/// before the setting was turned on must not then download over cellular.
|
||||
func applyNetworkPolicy() {
|
||||
queueReason = switch path?.status {
|
||||
case .satisfied?: allowed ? nil : "Waiting for Wi-Fi"
|
||||
default: "Waiting for a connection"
|
||||
}
|
||||
if allowed {
|
||||
resumeQueued()
|
||||
} else {
|
||||
|
||||
@@ -109,6 +109,9 @@ final class MpvPlayer {
|
||||
private init() {
|
||||
layer.framebufferOnly = true
|
||||
layer.backgroundColor = UIColor.black.cgColor
|
||||
// For the frames between a resize and mpv's next one: a drawable of
|
||||
// the old shape is letterboxed rather than stretched.
|
||||
layer.contentsGravity = .resizeAspect
|
||||
guard let mpv = mpv_create() else { return }
|
||||
// mpv takes the layer's address as an int64, not a Swift reference.
|
||||
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
|
||||
@@ -336,7 +339,12 @@ final class MpvPlayer {
|
||||
paused = value
|
||||
onStateChanged?()
|
||||
case 5: muted = value
|
||||
case 8: if !loading { buffering = value }
|
||||
case 8:
|
||||
// Said, so the lock screen's clock stops through a stall.
|
||||
if !loading, buffering != value {
|
||||
buffering = value
|
||||
onStateChanged?()
|
||||
}
|
||||
case 13: if !loading { seeking = value }
|
||||
default: break
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ let upNextSeconds = 10
|
||||
/// Past this much of an episode it counts as watched.
|
||||
private let watchedFraction = 0.9
|
||||
|
||||
/// How long a stall lasts before the player offers to open the episode again.
|
||||
private let stallLimit: TimeInterval = 20
|
||||
|
||||
/// How close to the end the next episode's opening blocks are fetched.
|
||||
private let warmNextSeconds = 120.0
|
||||
|
||||
/// How an episode is named in watch state and player readings.
|
||||
func mediaKey(_ episode: EpisodeRecord) -> String { "\(episode.shareId):\(episode.linkId)" }
|
||||
|
||||
@@ -72,11 +78,27 @@ final class PlayerHost {
|
||||
private(set) var index = 0
|
||||
/// Playing, but not on screen: the episode keeps going and the mini
|
||||
/// transport is what says so.
|
||||
var minimized = false
|
||||
var minimized = false {
|
||||
// The mini transport has no picture to keep awake for; back on screen
|
||||
// and still playing, it has one again. Nothing else recomputes this
|
||||
// until the next pause, so the screen slept under the video.
|
||||
didSet { updateIdleTimer() }
|
||||
}
|
||||
/// The open file's chapters, already resolved into openings and endings.
|
||||
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
/// What stopped this episode, until the viewer dismisses it.
|
||||
var playbackError: String?
|
||||
/// The stream is being opened: the share session, the revision and its
|
||||
/// block list, all before mpv has a byte. On an old iPad that is seconds
|
||||
/// of a black screen unless something says so.
|
||||
private(set) var opening = false
|
||||
/// Where a retry reopens the episode, when it got that far.
|
||||
private var retryFrom: Double?
|
||||
/// When the current stall began; see `tick`.
|
||||
private var stalledSince: Date?
|
||||
/// Cancelled from the end-of-file card: the viewer chose to stay, so the
|
||||
/// end of this file does not start the next one.
|
||||
var stay = false
|
||||
/// Read once per episode: a preference the viewer changes mid-episode
|
||||
/// applies to the next one.
|
||||
private(set) var autoSkip = false
|
||||
@@ -96,6 +118,8 @@ final class PlayerHost {
|
||||
/// Chapters already auto-skipped in this episode: seeking back into an
|
||||
/// opening on purpose must not be undone by the setting that skipped it.
|
||||
@ObservationIgnored private var skipped = Set<Int64>()
|
||||
/// The episode whose next one has been warmed; see `tick`.
|
||||
@ObservationIgnored private var warmedAfter: String?
|
||||
@ObservationIgnored private var pausedByInterruption = false
|
||||
@ObservationIgnored private var artworkFor: String?
|
||||
|
||||
@@ -107,6 +131,11 @@ final class PlayerHost {
|
||||
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
|
||||
var key: String? { episode.map(mediaKey) }
|
||||
var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil }
|
||||
/// Whether mpv's readings — position, chapters — are the open episode's.
|
||||
/// Not while the next one opens: the last one's credits go on playing
|
||||
/// until it is loaded, and read against the new index they offered the
|
||||
/// episode *after* the next as up next.
|
||||
var playingOpenEpisode: Bool { player?.media != nil && player?.media == key }
|
||||
var isOpen: Bool { title != nil }
|
||||
|
||||
// MARK: - Opening
|
||||
@@ -177,6 +206,7 @@ final class PlayerHost {
|
||||
index = 0
|
||||
minimized = false
|
||||
playbackError = nil
|
||||
opening = false
|
||||
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||
OrientationLock.set(false)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
@@ -185,8 +215,30 @@ final class PlayerHost {
|
||||
onClosed?()
|
||||
}
|
||||
|
||||
/// Play the open episode again from the start. mpv unloads a file at its
|
||||
/// end, so this opens it again rather than seeking.
|
||||
func replay() {
|
||||
guard episode != nil else { return }
|
||||
retryFrom = 0
|
||||
load()
|
||||
}
|
||||
|
||||
/// Open the episode again from scratch — a new stream, through the
|
||||
/// bridge's session refresh — where the failed one stopped.
|
||||
func retry() {
|
||||
guard episode != nil else { return }
|
||||
if let player, player.media == key, player.position > 0 { retryFrom = player.position }
|
||||
player?.problem = nil
|
||||
load()
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let title, let episode else { return }
|
||||
// Taken once: only the load a retry asked for starts from it.
|
||||
let resumeFrom = retryFrom
|
||||
retryFrom = nil
|
||||
stalledSince = nil
|
||||
stay = false
|
||||
loadTask?.cancel()
|
||||
session?.close()
|
||||
playbackError = nil
|
||||
@@ -210,6 +262,7 @@ final class PlayerHost {
|
||||
return
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
opening = true
|
||||
loadTask = Task {
|
||||
do {
|
||||
try await session.open()
|
||||
@@ -237,13 +290,15 @@ final class PlayerHost {
|
||||
handle: handle,
|
||||
// A finished episode restarts rather than resuming three
|
||||
// seconds from its own credits.
|
||||
start: watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0,
|
||||
start: resumeFrom ?? watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0,
|
||||
audio: show?.audioLanguage ?? global.audioLanguage,
|
||||
subtitle: show?.subtitleLanguage ?? global.subtitleLanguage,
|
||||
subtitles: show?.subtitles ?? global.subtitles,
|
||||
hardwareDecoding: SettingsStore().hardwareDecoding
|
||||
)
|
||||
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
|
||||
// mpv's own buffering takes over the spinner from here.
|
||||
opening = false
|
||||
session.transferToPlayer()
|
||||
// After the load, because mpv keeps none of the three across one.
|
||||
player.setVolume(global.volume)
|
||||
@@ -256,6 +311,7 @@ final class PlayerHost {
|
||||
abandon(session)
|
||||
return
|
||||
}
|
||||
opening = false
|
||||
playbackError = errorMessage(error)
|
||||
}
|
||||
}
|
||||
@@ -311,7 +367,7 @@ final class PlayerHost {
|
||||
guard media == key else { return }
|
||||
save()
|
||||
// Autoplay fires on a clean end of file only — never on a failure.
|
||||
if reason == .eof, autoplay, next != nil {
|
||||
if reason == .eof, autoplay, !stay, next != nil {
|
||||
advance()
|
||||
} else if reason == .failed {
|
||||
playbackError = player?.problem ?? "This episode could not be played."
|
||||
@@ -320,14 +376,40 @@ final class PlayerHost {
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard let player else { return }
|
||||
guard let player, playingOpenEpisode else { return }
|
||||
replanChapters(player)
|
||||
// A stall that does not clear is a dropped connection far more often
|
||||
// than a slow one, and "Buffering…" for ever offers no way out of it.
|
||||
if player.buffering, !player.paused {
|
||||
let since = stalledSince ?? .now
|
||||
stalledSince = since
|
||||
if playbackError == nil, Date.now.timeIntervalSince(since) >= stallLimit {
|
||||
playbackError = "Still loading. The connection may have dropped."
|
||||
}
|
||||
} else {
|
||||
stalledSince = nil
|
||||
}
|
||||
// Auto-skip, once per chapter.
|
||||
if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position),
|
||||
let chapter = chapters.entries.last(where: { player.position + 0.001 >= $0.start }),
|
||||
skipped.insert(chapter.index).inserted {
|
||||
player.seek(to: offer.target)
|
||||
}
|
||||
// Near the end, fetch the next episode's opening blocks, so autoplay
|
||||
// or "Play now" starts from the cache. The last two minutes or the
|
||||
// credits, whichever comes first: late enough that stopping after one
|
||||
// episode has not cost a download, early enough to matter.
|
||||
let nearEnd = player.duration > 0 && (
|
||||
player.duration - player.position < warmNextSeconds
|
||||
|| chapters.creditsStart.map { player.position >= $0 } == true
|
||||
)
|
||||
if nearEnd, let key, warmedAfter != key, let next {
|
||||
warmedAfter = key
|
||||
let (shareId, volumeId, linkId) = (next.shareId, next.volumeId, next.linkId)
|
||||
Task.detached {
|
||||
try? await NativeRuntime.engine().warmEpisode(shareId: shareId, volumeId: volumeId, linkId: linkId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the chapters when there is a reason to: a different file, or a
|
||||
@@ -343,7 +425,7 @@ final class PlayerHost {
|
||||
|
||||
/// The skip button's offer at the current position, if any.
|
||||
var offer: SkipOffer? {
|
||||
guard let player else { return nil }
|
||||
guard let player, playingOpenEpisode else { return nil }
|
||||
return skipOffer(plan: chapters, position: player.position)
|
||||
}
|
||||
|
||||
@@ -422,9 +504,11 @@ final class PlayerHost {
|
||||
func enteredBackground() {
|
||||
save()
|
||||
guard isOpen, let player else { return }
|
||||
if SettingsStore().backgroundAudio {
|
||||
player.setVideoEnabled(false)
|
||||
} else {
|
||||
// The picture goes either way: with background audio off the lock
|
||||
// screen's Play can still start the file, and decoding video into a
|
||||
// layer nobody sees is work iOS ends a process for.
|
||||
player.setVideoEnabled(false)
|
||||
if !SettingsStore().backgroundAudio {
|
||||
player.setPaused(true)
|
||||
}
|
||||
publishNowPlaying()
|
||||
@@ -451,12 +535,15 @@ final class PlayerHost {
|
||||
info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||
info[MPMediaItemPropertyPlaybackDuration] = player?.duration ?? 0
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player?.position ?? 0
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = (player?.paused ?? true) ? 0.0 : speed
|
||||
// Zero while it waits on the network too, or the lock screen's clock
|
||||
// runs on through a stall.
|
||||
let moving = !(player?.paused ?? true) && !(player?.buffering ?? false)
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = moving ? speed : 0.0
|
||||
center.nowPlayingInfo = info
|
||||
let commands = MPRemoteCommandCenter.shared()
|
||||
commands.nextTrackCommand.isEnabled = next != nil
|
||||
commands.previousTrackCommand.isEnabled = index > 0
|
||||
UIApplication.shared.isIdleTimerDisabled = !(player?.paused ?? true) && !minimized
|
||||
updateIdleTimer()
|
||||
|
||||
let artKey = mediaKey(episode)
|
||||
if artworkFor != artKey {
|
||||
@@ -474,6 +561,11 @@ final class PlayerHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the screen on while a picture is playing on it, and only then.
|
||||
private func updateIdleTimer() {
|
||||
UIApplication.shared.isIdleTimerDisabled = isOpen && !(player?.paused ?? true) && !minimized
|
||||
}
|
||||
|
||||
private func updateElapsed(_ seconds: Double) {
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
guard var info = center.nowPlayingInfo else { return }
|
||||
@@ -508,6 +600,18 @@ final class PlayerHost {
|
||||
MainActor.assumeIsolated { self?.retreat() }
|
||||
return .success
|
||||
}
|
||||
// Ten seconds either way: what a video's lock-screen transport offers
|
||||
// in place of a music player's previous and next.
|
||||
commands.skipForwardCommand.preferredIntervals = [10]
|
||||
commands.skipForwardCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.seek(to: (self?.player?.position ?? 0) + 10) }
|
||||
return .success
|
||||
}
|
||||
commands.skipBackwardCommand.preferredIntervals = [10]
|
||||
commands.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.seek(to: max((self?.player?.position ?? 0) - 10, 0)) }
|
||||
return .success
|
||||
}
|
||||
commands.stopCommand.addTarget { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.close() }
|
||||
return .success
|
||||
|
||||
@@ -7,7 +7,13 @@ private let controlsSeconds = 4.0
|
||||
/// The narrowest layout the in-app volume slider is worth the room it costs.
|
||||
/// Below it — a phone held upright — the hardware volume buttons are the
|
||||
/// better answer.
|
||||
private let volumeSliderMinWidth: CGFloat = 600
|
||||
private let volumeSliderMinWidth: CGFloat = 720
|
||||
|
||||
/// Narrower than this, the pickers past subtitles go behind "More".
|
||||
private let inlineControlsMinWidth: CGFloat = 560
|
||||
|
||||
/// How far a double tap on either side of the picture seeks.
|
||||
private let doubleTapSeconds = 10
|
||||
|
||||
/// The rates worth offering. A short list rather than a slider: the useful
|
||||
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
|
||||
@@ -24,17 +30,47 @@ struct PlayerView: View {
|
||||
@State private var showControls = true
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
@State private var landscapeLocked = OrientationLock.landscape
|
||||
/// Set by the rotation button: from then on the viewer decides.
|
||||
@State private var rotationChosen = false
|
||||
@State private var sheet: PlayerSheet?
|
||||
/// Which side was double-tapped, shown for a moment so the seek is seen
|
||||
/// to have happened; the count makes a second tap on one side show again.
|
||||
@State private var nudge: (seconds: Int, count: Int)?
|
||||
@Environment(\.accessibilityVoiceOverEnabled) private var voiceOver
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
MpvView(player: player).ignoresSafeArea()
|
||||
// Double-tap a side to seek ten seconds, as every phone video
|
||||
// player has taught; the middle plays or pauses. A single tap
|
||||
// waits out the double-tap, which is the price of both.
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture(count: 2) { location in doubleTap(at: location.x, width: geometry.size.width) }
|
||||
.onTapGesture { setControls(!showControls) }
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel(showControls ? "Hide controls" : "Show controls")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityAction { setControls(!showControls) }
|
||||
|
||||
if let nudge {
|
||||
Text(nudge.seconds < 0 ? "−\(-nudge.seconds) s" : "+\(nudge.seconds) s")
|
||||
.textStyle(.labelLarge)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 72, height: 72)
|
||||
.background(Circle().fill(scheme.scrim.opacity(0.5)))
|
||||
.padding(.horizontal, 48)
|
||||
.frame(maxWidth: .infinity, alignment: nudge.seconds < 0 ? .leading : .trailing)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
.task(id: nudge.count) {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
if !Task.isCancelled { self.nudge = nil }
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if showControls { topBar.transition(.opacity) }
|
||||
@@ -44,8 +80,12 @@ struct PlayerView: View {
|
||||
host: host,
|
||||
player: player,
|
||||
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
|
||||
roomForAll: geometry.size.width >= inlineControlsMinWidth,
|
||||
landscapeLocked: landscapeLocked,
|
||||
onRotate: { landscapeLocked = OrientationLock.toggle() },
|
||||
onRotate: {
|
||||
rotationChosen = true
|
||||
landscapeLocked = OrientationLock.toggle()
|
||||
},
|
||||
onSheet: { sheet = $0 },
|
||||
onInteract: { setControls(true) }
|
||||
)
|
||||
@@ -61,10 +101,24 @@ struct PlayerView: View {
|
||||
if let offer = host.offer, !host.autoSkip {
|
||||
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
|
||||
}
|
||||
// A file with no chapters has no credits to count over, so
|
||||
// the card takes its last seconds instead, on the file's
|
||||
// own clock: the end of file is what advances.
|
||||
let tailLeft: Double? = {
|
||||
let left = player.duration - player.position
|
||||
guard host.chapters.creditsStart == nil,
|
||||
player.duration > Double(upNextSeconds * 6),
|
||||
left <= Double(upNextSeconds) else { return nil }
|
||||
return left
|
||||
}()
|
||||
UpNextCard(
|
||||
next: host.next,
|
||||
visible: host.autoplay && !player.paused && host.chapters.creditsStart.map { player.position >= $0 } == true,
|
||||
onPlayNow: host.advance
|
||||
visible: host.autoplay && !host.stay && !player.paused && player.endReason == nil
|
||||
&& host.playingOpenEpisode
|
||||
&& (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true),
|
||||
secondsLeft: tailLeft.map { Int($0.rounded(.up)) },
|
||||
onPlayNow: host.advance,
|
||||
onStay: { host.stay = true }
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
@@ -73,10 +127,12 @@ struct PlayerView: View {
|
||||
|
||||
// Only while nothing else explains the still picture: an error
|
||||
// is the more useful thing to say.
|
||||
if player.stalled && host.playbackError == nil {
|
||||
if (host.opening || player.stalled) && host.playbackError == nil {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().controlSize(.large).tint(scheme.primary)
|
||||
if player.buffering {
|
||||
if host.opening {
|
||||
Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||
} else if player.buffering {
|
||||
Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…")
|
||||
.textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
|
||||
}
|
||||
@@ -85,15 +141,27 @@ struct PlayerView: View {
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
}
|
||||
|
||||
// Played to the end with nothing starting after it: the last
|
||||
// episode, autoplay off, or the viewer stayed.
|
||||
if host.playingOpenEpisode, player.endReason == .eof,
|
||||
host.next == nil || !host.autoplay || host.stay {
|
||||
EndCard(host: host)
|
||||
}
|
||||
|
||||
if let message = host.playbackError ?? player.problem {
|
||||
VStack(spacing: 4) {
|
||||
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
|
||||
// Dismissible, because the message is not always fatal
|
||||
// — a subtitle track that failed to load leaves an
|
||||
// episode that plays perfectly well behind it.
|
||||
Button("Dismiss", action: host.dismissProblem)
|
||||
.buttonStyle(.quiet)
|
||||
.tint(scheme.onErrorContainer)
|
||||
HStack {
|
||||
Button("Dismiss", action: host.dismissProblem)
|
||||
.buttonStyle(.quiet)
|
||||
.tint(scheme.onErrorContainer)
|
||||
Button("Retry", action: host.retry)
|
||||
.buttonStyle(.quiet)
|
||||
.tint(scheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
.textStyle(.bodyLarge)
|
||||
.padding(16)
|
||||
@@ -107,6 +175,16 @@ struct PlayerView: View {
|
||||
.persistentSystemOverlays(.hidden)
|
||||
.onAppear { setControls(true) }
|
||||
.onChange(of: player.paused) { setControls(showControls) }
|
||||
// VoiceOver moves between controls by swiping, and a control that
|
||||
// fades four seconds after it is reached cannot be found again.
|
||||
.onChange(of: voiceOver) { setControls(true) }
|
||||
// A wide picture fills a phone only on its side, so it turns there by
|
||||
// itself, once per episode, until the viewer picks for themselves.
|
||||
.onChange(of: player.videoWidth > player.videoHeight && player.videoHeight > 0) { _, wide in
|
||||
guard wide, host.playingOpenEpisode, !rotationChosen, !landscapeLocked else { return }
|
||||
OrientationLock.set(true)
|
||||
landscapeLocked = true
|
||||
}
|
||||
.sheet(item: $sheet) { which in
|
||||
switch which {
|
||||
case .speed: SpeedSheet(current: host.speed, onSelect: host.setSpeed)
|
||||
@@ -152,12 +230,23 @@ struct PlayerView: View {
|
||||
private func setControls(_ shown: Bool) {
|
||||
showControls = shown
|
||||
hideTask?.cancel()
|
||||
guard shown, !player.paused else { return }
|
||||
guard shown, !player.paused, !voiceOver else { return }
|
||||
hideTask = Task {
|
||||
try? await Task.sleep(for: .seconds(controlsSeconds))
|
||||
if !Task.isCancelled { showControls = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func doubleTap(at x: CGFloat, width: CGFloat) {
|
||||
let third = width / 3
|
||||
let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0
|
||||
if seconds == 0 {
|
||||
host.setPaused(!player.paused)
|
||||
} else {
|
||||
host.seek(to: max(player.position + Double(seconds), 0))
|
||||
nudge = (seconds, (nudge?.count ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlayerSheet: Identifiable {
|
||||
@@ -181,6 +270,7 @@ private struct PlayerControls: View {
|
||||
let host: PlayerHost
|
||||
let player: MpvPlayer
|
||||
let roomForVolume: Bool
|
||||
let roomForAll: Bool
|
||||
let landscapeLocked: Bool
|
||||
let onRotate: () -> Void
|
||||
let onSheet: (PlayerSheet) -> Void
|
||||
@@ -229,49 +319,86 @@ private struct PlayerControls: View {
|
||||
CompactIcon("goforward.30", "Forward thirty seconds") { host.seek(to: player.position + 30) }
|
||||
CompactIcon("forward.end.fill", "Next episode", enabled: host.next != nil, action: host.advance)
|
||||
Spacer(minLength: 4)
|
||||
if host.episodes.count > 1 {
|
||||
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
|
||||
}
|
||||
if host.chapters.entries.count > 1 {
|
||||
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||||
}
|
||||
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
|
||||
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
|
||||
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||||
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", "Mute") {
|
||||
player.setMuted(!player.muted)
|
||||
host.rememberVolume(muted: !player.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.
|
||||
if roomForVolume {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||||
set: { dragVolume = $0; player.setVolume($0) }
|
||||
),
|
||||
in: 0 ... 100
|
||||
) { editing in
|
||||
onInteract()
|
||||
if !editing, let chosen = dragVolume {
|
||||
dragVolume = nil
|
||||
host.rememberVolume(volume: chosen)
|
||||
if roomForAll {
|
||||
pickers
|
||||
} else {
|
||||
// A phone held upright has room for the transport and two
|
||||
// more buttons at a thumb's size; the rest go behind More
|
||||
// rather than off the edge.
|
||||
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||||
Menu {
|
||||
if host.episodes.count > 1 {
|
||||
Button("Episodes", systemImage: "rectangle.stack") { onSheet(.episodes) }
|
||||
}
|
||||
if host.chapters.entries.count > 1 {
|
||||
Button("Chapters", systemImage: "list.bullet") { onSheet(.chapters) }
|
||||
}
|
||||
Button("Playback speed", systemImage: "speedometer") { onSheet(.speed) }
|
||||
Button("Audio track", systemImage: "waveform") { onSheet(.tracks("audio")) }
|
||||
Button(player.muted ? "Unmute" : "Mute", systemImage: player.muted ? "speaker.wave.2.fill" : "speaker.slash.fill", action: toggleMute)
|
||||
Button(
|
||||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||
systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
action: onRotate
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.tint(scheme.primary)
|
||||
.frame(width: 96)
|
||||
.accessibilityLabel("More")
|
||||
}
|
||||
CompactIcon(
|
||||
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||
action: onRotate
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
|
||||
}
|
||||
|
||||
private func toggleMute() {
|
||||
player.setMuted(!player.muted)
|
||||
host.rememberVolume(muted: !player.muted)
|
||||
}
|
||||
|
||||
/// The pickers, laid out in a row where there is room for them all.
|
||||
@ViewBuilder private var pickers: some View {
|
||||
if host.episodes.count > 1 {
|
||||
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
|
||||
}
|
||||
if host.chapters.entries.count > 1 {
|
||||
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||||
}
|
||||
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
|
||||
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
|
||||
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
|
||||
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", player.muted ? "Unmute" : "Mute", action: toggleMute)
|
||||
// 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.
|
||||
if roomForVolume {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||||
set: { dragVolume = $0; player.setVolume($0) }
|
||||
),
|
||||
in: 0 ... 100
|
||||
) { editing in
|
||||
onInteract()
|
||||
if !editing, let chosen = dragVolume {
|
||||
dragVolume = nil
|
||||
host.rememberVolume(volume: chosen)
|
||||
}
|
||||
}
|
||||
.tint(scheme.primary)
|
||||
.frame(width: 96)
|
||||
}
|
||||
CompactIcon(
|
||||
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||
action: onRotate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CompactIcon: View {
|
||||
@@ -290,12 +417,13 @@ private struct CompactIcon: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 19))
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
|
||||
.frame(width: 40, height: 40)
|
||||
// 44pt: the smallest target Apple's guidelines allow.
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(PressableStyle(circle: true, wash: .white))
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
@@ -308,7 +436,11 @@ private struct CompactIcon: View {
|
||||
private struct UpNextCard: View {
|
||||
let next: EpisodeRecord?
|
||||
let visible: Bool
|
||||
/// Counting the end of a file with no credits chapter, on the file's own
|
||||
/// clock; the second button then cancels rather than deferring.
|
||||
let secondsLeft: Int?
|
||||
let onPlayNow: () -> Void
|
||||
let onStay: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var held = false
|
||||
@State private var remaining = upNextSeconds
|
||||
@@ -332,26 +464,27 @@ private struct UpNextCard: View {
|
||||
Circle().fill(.black.opacity(0.5))
|
||||
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(remaining) / Double(upNextSeconds))
|
||||
.trim(from: 0, to: Double(secondsLeft ?? remaining) / Double(upNextSeconds))
|
||||
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 1), value: remaining)
|
||||
.animation(.linear(duration: 1), value: secondsLeft ?? remaining)
|
||||
Image(systemName: "play.fill").foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(PressableStyle(wash: .white))
|
||||
.accessibilityLabel("Play now")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Text("Up next in \(secondsLeft ?? remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||
}
|
||||
}
|
||||
Button("Watch till the end") {
|
||||
Button(secondsLeft == nil ? "Watch till the end" : "Cancel") {
|
||||
held = true
|
||||
heldFor = upNext.linkId
|
||||
if secondsLeft != nil { onStay() }
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
}
|
||||
@@ -359,6 +492,7 @@ private struct UpNextCard: View {
|
||||
.frame(width: 280)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
.task(id: upNext.linkId) {
|
||||
guard secondsLeft == nil else { return }
|
||||
remaining = upNextSeconds
|
||||
while remaining > 0 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
@@ -372,6 +506,35 @@ private struct UpNextCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player says once an episode ends and nothing starts after it.
|
||||
private struct EndCard: View {
|
||||
let host: PlayerHost
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text(host.next == nil
|
||||
? "That was the last episode of \(host.title?.displayName ?? "this title")"
|
||||
: "Finished \(host.episode?.label ?? "this episode")")
|
||||
.textStyle(.titleMedium)
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.multilineTextAlignment(.center)
|
||||
HStack(spacing: 8) {
|
||||
if host.next != nil {
|
||||
Button("Play next", action: host.advance).buttonStyle(.accent)
|
||||
} else {
|
||||
Button("Watch again", action: host.replay).buttonStyle(.quiet)
|
||||
}
|
||||
Button("Close", action: host.close).buttonStyle(.quiet)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 360)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SpeedSheet: View {
|
||||
let current: Double
|
||||
let onSelect: (Double) -> Void
|
||||
@@ -515,7 +678,7 @@ private struct EpisodeSheet: View {
|
||||
.background(current ? scheme.secondaryContainer : .clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,9 +730,9 @@ struct MpvView: UIViewRepresentable {
|
||||
func updateUIView(_: LayerView, context _: Context) {}
|
||||
|
||||
final class LayerView: UIView {
|
||||
private let video: CALayer
|
||||
private let video: CAMetalLayer
|
||||
|
||||
init(layer video: CALayer) {
|
||||
init(layer video: CAMetalLayer) {
|
||||
self.video = video
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .black
|
||||
@@ -580,14 +743,30 @@ struct MpvView: UIViewRepresentable {
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) { nil }
|
||||
|
||||
/// The drawable is sized here, not left to the layer. MoltenVK sets
|
||||
/// `drawableSize` once when it builds the swapchain and Core Animation
|
||||
/// stops tracking the bounds from then on, while mpv decides its
|
||||
/// output size from `drawableSize` alone. Left alone, a rotation, a
|
||||
/// Split View resize or the player reopening at another size kept the
|
||||
/// old drawable, which Core Animation stretched over the new bounds —
|
||||
/// a squashed or cropped picture until something else resized it.
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
video.frame = bounds
|
||||
video.contentsScale = window?.screen.scale ?? UIScreen.main.scale
|
||||
video.contentsScale = scale
|
||||
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
// Reattached after the player was minimised: the layer is the one
|
||||
// from before, still sized for wherever it last was.
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ struct AccountCard: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Proton account").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
Text("Optional. Signed in, watch history is kept in your own Drive so every device resumes where another left off, and folders of your Drive can join the library.")
|
||||
Text("Optional. Signed in, your shares, settings and watch history are kept in your own Drive, so every device shows the same library and resumes where another left off, and folders of your Drive can join the library.")
|
||||
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Button("Sign in", action: onSignIn).buttonStyle(.tonal)
|
||||
}
|
||||
@@ -61,8 +61,8 @@ struct AccountCard: View {
|
||||
|
||||
/// "Synced 3 min ago · 2 from other devices", or why it did not.
|
||||
func syncLine(_ account: AccountUiState, now: Date) -> String {
|
||||
if let error = account.syncError { return "Watch history did not sync: \(error)" }
|
||||
guard let at = account.syncedAt else { return "Watch history syncs with your Drive" }
|
||||
if let error = account.syncError { return "Did not sync: \(error)" }
|
||||
guard let at = account.syncedAt else { return "Library, settings and watch history sync through your Drive" }
|
||||
let minutes = Int(max(now.timeIntervalSince(at), 0) / 60)
|
||||
let ago = minutes < 1 ? "just now" : minutes < 60 ? "\(minutes) min ago" : "\(minutes / 60) h ago"
|
||||
let applied = account.applied > 0 ? " · \(account.applied) from other devices" : ""
|
||||
@@ -383,7 +383,7 @@ private struct DriveRow: View {
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.disabled(open == nil)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ struct IconButton: View {
|
||||
.frame(width: 48, height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressableIcon)
|
||||
.foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38))
|
||||
.disabled(!enabled)
|
||||
.accessibilityLabel(label)
|
||||
@@ -79,17 +79,22 @@ struct IconButton: View {
|
||||
struct EmptyState: View {
|
||||
let title: String
|
||||
let body_: String
|
||||
let action: (label: String, run: () -> Void)?
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
init(_ title: String, _ body: String) {
|
||||
init(_ title: String, _ body: String, action: (label: String, run: () -> Void)? = nil) {
|
||||
self.title = title
|
||||
body_ = body
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(title).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
Text(body_).textStyle(.bodyMedium).foregroundStyle(scheme.onSurface)
|
||||
Text(body_).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
if let action {
|
||||
Button(action.label, action: action.run).buttonStyle(.accent).padding(.top, 16)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(24)
|
||||
@@ -263,15 +268,54 @@ extension View {
|
||||
func secureContent() -> some View { modifier(SecureContent()) }
|
||||
}
|
||||
|
||||
/// Where Play on a title lands: the most recently played part-watched episode,
|
||||
/// else the first unwatched one, else the first — the desktop's `next_up`.
|
||||
func nextUpIndex(_ playlist: [EpisodeRecord]) -> Int {
|
||||
/// Where Play on a title lands: what Continue watching offers
|
||||
/// (`TitleRecord.continueIndex`), else the most recently played part-watched
|
||||
/// episode, else the first unwatched one, else the first — the desktop's
|
||||
/// `next_up`.
|
||||
func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> Int {
|
||||
if let index = continueIndex.map(Int.init), playlist.indices.contains(index) {
|
||||
return index
|
||||
}
|
||||
if let resumed = playlist.indices.filter({ playlist[$0].resumeAt != nil }).max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed }) {
|
||||
return resumed
|
||||
}
|
||||
return playlist.firstIndex { !$0.watched } ?? 0
|
||||
}
|
||||
|
||||
/// Episodes asked for offline at once, waiting on a yes.
|
||||
struct DownloadRequest: Identifiable {
|
||||
let id = UUID()
|
||||
let episodes: [EpisodeRecord]
|
||||
let what: String
|
||||
}
|
||||
|
||||
/// Download episodes, or return the question to ask first when it is more
|
||||
/// than one: a season or a show is tens of gigabytes, and one tap should not
|
||||
/// fill a phone. Show the question with `confirmingDownload`.
|
||||
@MainActor func download(_ episodes: [EpisodeRecord], _ what: String) -> DownloadRequest? {
|
||||
let wanted = episodes.filter { !$0.offline }
|
||||
if wanted.count > 1 { return DownloadRequest(episodes: wanted, what: what) }
|
||||
DownloadCoordinator.shared.enqueue(wanted)
|
||||
return nil
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Ask about a `download` of several episodes, with how many and how big.
|
||||
func confirmingDownload(_ request: Binding<DownloadRequest?>) -> some View {
|
||||
alert(
|
||||
request.wrappedValue.map { "Download \($0.episodes.count) episodes of \($0.what)?" } ?? "",
|
||||
isPresented: Binding(get: { request.wrappedValue != nil }, set: { if !$0 { request.wrappedValue = nil } }),
|
||||
presenting: request.wrappedValue
|
||||
) { asked in
|
||||
Button("Download") { DownloadCoordinator.shared.enqueue(asked.episodes) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { asked in
|
||||
let bytes = asked.episodes.reduce(UInt64(0)) { $0 + ($1.size ?? 0) }
|
||||
Text("They are kept on this device for watching without a connection\(bytes > 0 ? " — about \(formatBytes(bytes))" : ""). Delete them from Downloads.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(_ bytes: UInt64) -> String {
|
||||
let value = Double(bytes)
|
||||
let gib = 1024.0 * 1024 * 1024
|
||||
@@ -283,9 +327,11 @@ func formatBytes(_ bytes: UInt64) -> String {
|
||||
}
|
||||
|
||||
/// One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
/// 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||
func downloadStatusLine(_ download: RetainedDownload) -> String {
|
||||
var parts = [download.status.prefix(1).uppercased() + download.status.dropFirst()]
|
||||
/// 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
|
||||
/// `waitingFor`, what it is really waiting on.
|
||||
func downloadStatusLine(_ download: RetainedDownload, waitingFor: String? = nil) -> String {
|
||||
let status = download.status == RetainedDownload.statusQueued ? waitingFor : nil
|
||||
var parts = [status ?? download.status.prefix(1).uppercased() + download.status.dropFirst()]
|
||||
if download.total > 0, download.downloaded > 0 {
|
||||
parts.append("\(formatBytes(UInt64(download.downloaded))) of \(formatBytes(UInt64(download.total)))")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ private struct OfflineGroup: Identifiable {
|
||||
}
|
||||
|
||||
struct DownloadsView: View {
|
||||
var onPlay: (TitleRecord, Int) -> Void = { _, _ in }
|
||||
var onOpen: (TitleRecord) -> Void = { _ in }
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scheme) private var scheme
|
||||
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||
@@ -48,7 +50,7 @@ struct DownloadsView: View {
|
||||
caption: "\(group.files.count) \(group.files.count == 1 ? "episode" : "episodes") · \(formatBytes(group.files.reduce(0) { $0 + $1.size }))"
|
||||
)
|
||||
ForEach(group.files, id: \.linkId) { file in
|
||||
SavedRow(file: file, title: group.title) { deleting = file }
|
||||
SavedRow(file: file, title: group.title, onPlay: onPlay, onOpen: onOpen) { deleting = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,11 +104,16 @@ private struct SectionHeading: View {
|
||||
private struct SavedRow: View {
|
||||
let file: OfflineRecord
|
||||
let title: TitleRecord?
|
||||
let onPlay: (TitleRecord, Int) -> Void
|
||||
let onOpen: (TitleRecord) -> Void
|
||||
let onDelete: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let episode = file.episode
|
||||
// What a download is for: tapping it plays it, from where it was
|
||||
// left, without going back through the library to find it.
|
||||
let index = title?.playlist.firstIndex { $0.linkId == file.linkId }
|
||||
HStack(spacing: 16) {
|
||||
RemoteArtwork(episode?.stillUrl ?? title?.backdropUrl, title?.name ?? file.linkId, fallback: episode?.thumbnailSource, labelled: episode == nil)
|
||||
.frame(width: 96)
|
||||
@@ -118,11 +125,21 @@ private struct SavedRow: View {
|
||||
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let title {
|
||||
IconButton("info.circle", "Open \(title.displayName)") { onOpen(title) }
|
||||
}
|
||||
IconButton("trash", "Delete download", action: onDelete)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if let title, let index { onPlay(title, index) }
|
||||
}
|
||||
.accessibilityAction(named: "Play") {
|
||||
if let title, let index { onPlay(title, index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +155,7 @@ private struct PartialRow: View {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(download.label).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||
Text(downloadStatusLine(download)).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Text(downloadStatusLine(download, waitingFor: DownloadCoordinator.shared.queueReason)).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
if download.total > 0 {
|
||||
AccentProgress(progress: Double(download.downloaded) / Double(download.total)).padding(.top, 8)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ struct LanguageSetting: View {
|
||||
Button { choosing = true } label: {
|
||||
ListRow(headline: headline, supporting: languageLabel(value))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.sheet(isPresented: $choosing) {
|
||||
LanguageSheet(headline: headline, value: value) { tag in
|
||||
choosing = false
|
||||
@@ -114,7 +114,7 @@ private struct LanguageSheet: View {
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ struct Resumable: Identifiable {
|
||||
let title: TitleRecord
|
||||
let episode: EpisodeRecord
|
||||
let index: Int
|
||||
/// When the title was last played, which orders the shelf.
|
||||
let lastPlayed: Int64
|
||||
|
||||
var id: String { "\(episode.shareId)/\(episode.linkId)" }
|
||||
}
|
||||
@@ -30,17 +32,22 @@ private struct Shelf: Identifiable {
|
||||
}
|
||||
|
||||
/// Most recently played first, one episode per show: a shelf that lists four
|
||||
/// episodes of the same series is a shelf with room for nothing else.
|
||||
/// episodes of the same series is a shelf with room for nothing else. Which
|
||||
/// episode is the engine's call (`continueIndex`): the part-watched one, or the
|
||||
/// one after the episode finished last.
|
||||
func resumable(_ titles: [TitleRecord]) -> [Resumable] {
|
||||
titles.compactMap { title -> Resumable? in
|
||||
guard let index = title.continueIndex.map(Int.init) else { return nil }
|
||||
let playlist = title.playlist
|
||||
guard let index = playlist.indices
|
||||
.filter({ playlist[$0].resumeAt != nil && !playlist[$0].watched })
|
||||
.max(by: { playlist[$0].lastPlayed < playlist[$1].lastPlayed })
|
||||
else { return nil }
|
||||
return Resumable(title: title, episode: playlist[index], index: index)
|
||||
guard playlist.indices.contains(index) else { return nil }
|
||||
return Resumable(
|
||||
title: title,
|
||||
episode: playlist[index],
|
||||
index: index,
|
||||
lastPlayed: playlist.map(\.lastPlayed).max() ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
||||
.sorted { $0.lastPlayed > $1.lastPlayed }
|
||||
.prefix(continueWatchingMax)
|
||||
.map { $0 }
|
||||
}
|
||||
@@ -50,11 +57,22 @@ struct LibraryView: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
let onResume: (TitleRecord, Int) -> Void
|
||||
let onTitle: (TitleRecord) -> Void
|
||||
var onAddShare: () -> Void = {}
|
||||
var onOpenSettings: () -> Void = {}
|
||||
|
||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||
@State private var menuFor: TitleRecord?
|
||||
@State private var menuResume: Resumable?
|
||||
@State private var matching: TitleRecord?
|
||||
@State private var downloading: DownloadRequest?
|
||||
// Posters and synopses, offered once there is a library to put them on
|
||||
// and never turned on unasked: switching them on sends title names out.
|
||||
@State private var offerDismissed = SettingsStore().metadataOfferDismissed
|
||||
/// The same, for a download asked about once the menu has gone.
|
||||
@State private var pendingDownload: DownloadRequest?
|
||||
/// "Change match" picked in the tile menu, waiting for that menu to finish
|
||||
/// leaving: a sheet presented while another is still dismissing is dropped.
|
||||
@State private var pendingMatch: TitleRecord?
|
||||
// Search is an icon until it is wanted: a permanent field above the
|
||||
// library spent a band of every visit on something used in few of them.
|
||||
@SceneStorage("library.searching") private var searching = false
|
||||
@@ -90,26 +108,31 @@ struct LibraryView: View {
|
||||
content(resumable)
|
||||
}
|
||||
.background(scheme.background)
|
||||
.sheet(item: $menuFor) { title in
|
||||
.sheet(item: $menuFor, onDismiss: {
|
||||
// After the dismissal rather than a guessed delay: on a slow iPad
|
||||
// the menu was still leaving after 350 ms, the search sheet was
|
||||
// dropped, and it turned up later on some unrelated redraw.
|
||||
if let title = pendingMatch {
|
||||
pendingMatch = nil
|
||||
matching = title
|
||||
}
|
||||
downloading = pendingDownload
|
||||
pendingDownload = nil
|
||||
}) { title in
|
||||
let playlist = title.playlist
|
||||
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
|
||||
TileMenu(heading: title.displayName, caption: title.caption) {
|
||||
MenuRow("play.fill", playlist.contains { $0.resumeAt != nil } ? "Resume" : "Play") {
|
||||
onResume(title, nextUpIndex(playlist))
|
||||
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
|
||||
}
|
||||
MenuRow("info.circle", "Open") { onTitle(title) }
|
||||
MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) }
|
||||
MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) }
|
||||
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
|
||||
model.setTitleWatched(title, !allWatched)
|
||||
}
|
||||
// The same search the title page offers, for the tile that is
|
||||
// plainly wrong — the wrong poster is what the grid shows.
|
||||
MenuRow("pencil", "Change match") {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
matching = title
|
||||
}
|
||||
}
|
||||
MenuRow("pencil", "Change match") { pendingMatch = title }
|
||||
}
|
||||
}
|
||||
.sheet(item: $menuResume) { entry in
|
||||
@@ -121,6 +144,7 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmingDownload($downloading)
|
||||
.sheet(item: $matching) { title in
|
||||
ChangeMatchSheet(title: title) { matching = nil }
|
||||
}
|
||||
@@ -131,9 +155,17 @@ struct LibraryView: View {
|
||||
LibrarySkeleton()
|
||||
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
EmptyState("Nothing matches", "No title in the library is called “\(model.query.trimmingCharacters(in: .whitespaces))”.")
|
||||
} else if model.titles.isEmpty, let activity = model.activity {
|
||||
// A share just added: say it is being looked through, rather than
|
||||
// offer to add the one that is already filling the page.
|
||||
EmptyState("Looking through the share", "\(activity) Its titles appear here once it has been listed.")
|
||||
} else if model.titles.isEmpty {
|
||||
ScrollView {
|
||||
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
|
||||
EmptyState(
|
||||
"Your library is empty",
|
||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||
action: ("Add a share", onAddShare)
|
||||
)
|
||||
.containerRelativeFrame(.vertical)
|
||||
}
|
||||
.refreshable { await model.crawlNow(nil) }
|
||||
@@ -168,6 +200,12 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if browsing, !model.metadataSettings.enabled, !offerDismissed {
|
||||
MetadataOfferCard(onOpenSettings: onOpenSettings) {
|
||||
offerDismissed = true
|
||||
SettingsStore().metadataOfferDismissed = true
|
||||
}
|
||||
}
|
||||
if let featured {
|
||||
FeaturedBanner(
|
||||
title: featured,
|
||||
@@ -183,14 +221,18 @@ struct LibraryView: View {
|
||||
LazyHStack(alignment: .top, spacing: 12) {
|
||||
ForEach(resumable) { entry in
|
||||
ContinueCard(entry: entry)
|
||||
.onTapGesture { onResume(entry.title, entry.index) }
|
||||
.onLongPressGesture { longPressed { menuResume = entry } }
|
||||
.tapAndHold { onResume(entry.title, entry.index) } onHold: {
|
||||
longPressed { menuResume = entry }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if browsing {
|
||||
// Only over the whole library: under a filter, a shelf of
|
||||
// titles the grid below is hiding reads as the filter not
|
||||
// working.
|
||||
if browsing && model.filter == .all {
|
||||
ForEach(shelves) { shelf in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(shelf.label).textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
@@ -199,8 +241,7 @@ struct LibraryView: View {
|
||||
ForEach(shelf.titles, id: \.key) { title in
|
||||
PosterTile(title: title)
|
||||
.frame(width: 112)
|
||||
.onTapGesture { onTitle(title) }
|
||||
.onLongPressGesture { longPressed { menuFor = title } }
|
||||
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,12 +251,25 @@ struct LibraryView: View {
|
||||
Text("All titles").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
if browsing {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SettingsStore.filters, id: \.self) { filter in
|
||||
FilterChip(label: filter.label, selected: filter == model.filter) { model.setFilter(filter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if tiles.isEmpty && model.filter != .all {
|
||||
Text("Nothing in the library fits that filter.")
|
||||
.textStyle(.bodyMedium)
|
||||
.foregroundStyle(scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
// Posters, three across on a phone.
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) {
|
||||
ForEach(tiles) { tile in
|
||||
PosterTile(title: tile.title, folded: tile.folded)
|
||||
.onTapGesture { onTitle(tile.title) }
|
||||
.onLongPressGesture { longPressed { menuFor = tile.title } }
|
||||
.tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,7 +411,7 @@ struct MenuRow: View {
|
||||
} label: {
|
||||
ListRow(icon: icon, headline: label)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
@@ -384,6 +438,43 @@ private struct LibrarySkeleton: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension LibraryFilter {
|
||||
/// The desktop's names for the filters, from `browse::Filter::label`.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .series: "Series"
|
||||
case .films: "Films"
|
||||
case .watching: "Watching"
|
||||
case .unwatched: "Not started"
|
||||
case .downloaded: "Downloaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The offer of posters and synopses, and its two answers.
|
||||
private struct MetadataOfferCard: View {
|
||||
let onOpenSettings: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Add posters and synopses?").textStyle(.titleSmall).foregroundStyle(scheme.onSurface)
|
||||
Text("Turning it on in Settings sends the titles in your library — not your files, and nothing about what you have watched — to AniList or TMDB.")
|
||||
.textStyle(.bodySmall)
|
||||
.foregroundStyle(scheme.onSurfaceVariant)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Not now", action: onDismiss).buttonStyle(.quiet)
|
||||
Button("Open settings", action: onOpenSettings).buttonStyle(.quiet)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 12, leading: 16, bottom: 4, trailing: 8))
|
||||
.background(scheme.surfaceContainer, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// The title the banner shows: the one watched last, or with nothing part
|
||||
/// watched, one of the titles the provider has a backdrop for, turning over
|
||||
/// once a day — the desktop's `featured`.
|
||||
@@ -421,7 +512,7 @@ private struct FeaturedBanner: View {
|
||||
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist)) } label: {
|
||||
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
|
||||
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
@@ -450,7 +541,9 @@ private struct ContinueCard: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Named by the text under it; read as well, the name came twice.
|
||||
RemoteArtwork(entry.episode.stillUrl ?? entry.title.backdropUrl, entry.title.displayName, fallback: entry.episode.thumbnailSource)
|
||||
.accessibilityHidden(true)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress = entry.episode.progress { AccentProgress(progress: progress) }
|
||||
@@ -458,7 +551,9 @@ private struct ContinueCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
Text(entry.title.displayName).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||||
.lineLimit(1).padding(.top, 8)
|
||||
Text([entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · "))
|
||||
Text(entry.episode.resumeAt == nil
|
||||
? "Next: \(entry.episode.label)"
|
||||
: [entry.episode.label, entry.episode.timeLeft].compactMap { $0 }.joined(separator: " · "))
|
||||
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||
}
|
||||
.frame(width: 220)
|
||||
@@ -478,6 +573,7 @@ struct PosterTile: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
|
||||
.accessibilityHidden(true)
|
||||
.aspectRatio(2 / 3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
@@ -488,6 +584,7 @@ struct PosterTile: View {
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||
.padding(6)
|
||||
.accessibilityLabel("and \(folded) more")
|
||||
}
|
||||
}
|
||||
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
|
||||
|
||||
@@ -259,7 +259,12 @@ final class ArtworkLoader: @unchecked Sendable {
|
||||
// A redirect must not leave HTTPS.
|
||||
http.url?.scheme?.lowercased() == "https"
|
||||
else { return nil }
|
||||
var bytes = Data()
|
||||
// Refused before reading when the server says up front it is too big.
|
||||
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
|
||||
// An array sized once, not a `Data` grown a byte at a time: on an old
|
||||
// iPad, a screen of posters read that way was seconds of CPU.
|
||||
var bytes = [UInt8]()
|
||||
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
|
||||
do {
|
||||
for try await byte in stream {
|
||||
bytes.append(byte)
|
||||
@@ -268,7 +273,7 @@ final class ArtworkLoader: @unchecked Sendable {
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
return bytes
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
/// Decode within a bound the display can actually use: the compressed size
|
||||
|
||||
@@ -42,6 +42,8 @@ struct SettingsView: View {
|
||||
@State private var prefs: PlaybackPrefsRecord?
|
||||
@State private var confirmDelete = false
|
||||
@State private var showMetadata = false
|
||||
/// How far an edge swipe back has gone, 0 to 1.
|
||||
@State private var backProgress: CGFloat = 0
|
||||
@State private var legalDocument: LegalDocument?
|
||||
|
||||
var body: some View {
|
||||
@@ -56,15 +58,22 @@ struct SettingsView: View {
|
||||
Button { page = entry } label: {
|
||||
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
prefs = try? await model.run { try $0.playbackPrefs() }
|
||||
// Read again whenever the list is shown, not once: sync can rewrite
|
||||
// them while the page is open, and a page holding the old copy would
|
||||
// write it back over the new one on the next switch.
|
||||
.task(id: page) {
|
||||
do {
|
||||
prefs = try await model.run { try $0.playbackPrefs() }
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
}
|
||||
}
|
||||
.alert("Delete all offline episodes?", isPresented: $confirmDelete) {
|
||||
Button("Delete", role: .destructive, action: model.removeAllOffline)
|
||||
@@ -75,8 +84,7 @@ struct SettingsView: View {
|
||||
.sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) }
|
||||
.sheet(isPresented: $showMetadata) {
|
||||
MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in
|
||||
model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
||||
showMetadata = false
|
||||
await model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,13 +117,27 @@ struct SettingsView: View {
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
// Back by an edge swipe, as from a title page.
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onEnded { drag in
|
||||
if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil }
|
||||
}
|
||||
)
|
||||
.scaleEffect(1 - 0.1 * backProgress)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
|
||||
// Back by an edge swipe, as from a title page — from a strip of its
|
||||
// own, as there. On the whole page the swipe began on whichever row
|
||||
// was under the edge, and lifting the finger pressed it: the way back
|
||||
// from Metadata opened the enrichment sheet.
|
||||
.overlay(alignment: .leading) {
|
||||
Color.clear
|
||||
.frame(width: 20)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
|
||||
.onEnded { drag in
|
||||
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
|
||||
self.page = nil
|
||||
}
|
||||
withAnimation(.spring) { backProgress = 0 }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var playback: some View {
|
||||
@@ -216,12 +238,23 @@ struct SettingsView: View {
|
||||
Binding(get: { value }, set: { on in update { change(&$0, on) } })
|
||||
}
|
||||
|
||||
/// Change a preference on screen at once, and store it. A store that
|
||||
/// fails says so and puts the switch back, rather than leaving one that
|
||||
/// looks saved and is not.
|
||||
private func update(_ change: (inout PlaybackPrefsRecord) -> Void) {
|
||||
guard var next = prefs else { return }
|
||||
let before = next
|
||||
change(&next)
|
||||
prefs = next
|
||||
let stored = next
|
||||
Task.detached { try? NativeRuntime.blockingEngine().setPlaybackPrefs(prefs: stored) }
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.setPlaybackPrefs(prefs: stored) }
|
||||
} catch {
|
||||
if prefs == stored { prefs = before }
|
||||
model.reportError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,22 +284,25 @@ private struct SettingAction: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetadataSettingsSheet: View {
|
||||
let current: MetadataSettingsRecord
|
||||
let onSave: (Bool, MetadataProvider, String, String) -> Void
|
||||
/// Resolves to why the settings were not stored, or nil once they are.
|
||||
let onSave: (Bool, MetadataProvider, String, String) async -> String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var enabled: Bool
|
||||
@State private var provider: MetadataProvider
|
||||
@State private var language: String
|
||||
@State private var apiKey = ""
|
||||
@State private var saving = false
|
||||
@State private var refusal: String?
|
||||
|
||||
init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) -> Void) {
|
||||
init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) async -> String?) {
|
||||
self.current = current
|
||||
self.onSave = onSave
|
||||
_enabled = State(initialValue: current.enabled)
|
||||
@@ -290,14 +326,34 @@ private struct MetadataSettingsSheet: View {
|
||||
Field(label: "Language", text: $language)
|
||||
Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
|
||||
}
|
||||
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error)
|
||||
}
|
||||
// Said before Save rather than after: what is forgotten includes
|
||||
// matches chosen by hand, which no look-up redoes.
|
||||
if current.enabled, !enabled || provider != current.provider {
|
||||
Text("Saving forgets every poster, description and match stored now, including ones you chose by hand.")
|
||||
.textStyle(.bodySmall)
|
||||
.foregroundStyle(scheme.error)
|
||||
}
|
||||
if let refusal {
|
||||
Text(refusal).foregroundStyle(scheme.error)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Save") {
|
||||
onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey)
|
||||
Button(saving ? "Saving…" : "Save") {
|
||||
saving = true
|
||||
refusal = nil
|
||||
Task {
|
||||
refusal = await onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey)
|
||||
saving = false
|
||||
if refusal == nil { dismiss() }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(!(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
||||
.disabled(saving || !(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
||||
}
|
||||
.interactiveDismissDisabled(saving)
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
@@ -408,9 +464,12 @@ private struct AppearancePicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected at once, so a second tap builds on the first rather than on
|
||||
/// what was there before it, and painted when Rust has resolved it.
|
||||
private func change(namesChanged: Bool = false, _ edit: (inout AppearanceRecord) -> Void) {
|
||||
guard var next = choice else { return }
|
||||
edit(&next)
|
||||
choice = next
|
||||
let chosen = next
|
||||
Task {
|
||||
await repaint(chosen, store: true)
|
||||
@@ -420,20 +479,29 @@ private struct AppearancePicker: View {
|
||||
|
||||
private func repaint(_ next: AppearanceRecord, store: Bool) async {
|
||||
let accents = Self.accents
|
||||
guard let (palette, row) = try? await model.run({ engine -> (PaletteRecord, [AccentChoice: UInt32]) in
|
||||
if store { try engine.setAppearance(appearance: next) }
|
||||
let palette = engine.previewPalette(appearance: next)
|
||||
// Every accent as it would look in *this* flavour: a swatch row that
|
||||
// keeps Mocha's pastels while Latte is selected lies about what the
|
||||
// next tap does.
|
||||
var row: [AccentChoice: UInt32] = [:]
|
||||
for accent in accents {
|
||||
var variant = next
|
||||
variant.accent = accent
|
||||
row[accent] = engine.previewPalette(appearance: variant).accent
|
||||
let resolved: (PaletteRecord, [AccentChoice: UInt32])
|
||||
do {
|
||||
resolved = try await model.run { engine -> (PaletteRecord, [AccentChoice: UInt32]) in
|
||||
if store { try engine.setAppearance(appearance: next) }
|
||||
let palette = engine.previewPalette(appearance: next)
|
||||
// Every accent as it would look in *this* flavour: a swatch row that
|
||||
// keeps Mocha's pastels while Latte is selected lies about what the
|
||||
// next tap does.
|
||||
var row: [AccentChoice: UInt32] = [:]
|
||||
for accent in accents {
|
||||
var variant = next
|
||||
variant.accent = accent
|
||||
row[accent] = engine.previewPalette(appearance: variant).accent
|
||||
}
|
||||
return (palette, row)
|
||||
}
|
||||
return (palette, row)
|
||||
}) else { return }
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
return
|
||||
}
|
||||
// A later tap has already moved on; its own repaint is on the way.
|
||||
if store, choice != next { return }
|
||||
let (palette, row) = resolved
|
||||
choice = next
|
||||
swatches = row.mapValues(argb)
|
||||
AppearanceState.shared.apply(palette)
|
||||
|
||||
@@ -6,12 +6,14 @@ struct SharesView: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
/// A link opened in this app: the Add form opens with it filled in.
|
||||
@Binding var incomingLink: String?
|
||||
var onFirstShareAdded: () -> Void = {}
|
||||
|
||||
@State private var showAdd = false
|
||||
@State private var signingIn = false
|
||||
@State private var browsing = false
|
||||
@State private var repairing: ShareRecord?
|
||||
@State private var removing: ShareRecord?
|
||||
@State private var renaming: ShareRecord?
|
||||
|
||||
var body: some View {
|
||||
TabPage("Shares") {
|
||||
@@ -33,6 +35,7 @@ struct SharesView: View {
|
||||
share: share,
|
||||
onRefresh: { model.refreshShare(share.id) },
|
||||
onRepair: { repairing = share },
|
||||
onRename: { renaming = share },
|
||||
onRemove: { removing = share }
|
||||
)
|
||||
}
|
||||
@@ -51,13 +54,16 @@ struct SharesView: View {
|
||||
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
||||
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
||||
model.addShare(name: name, url: url, password: password)
|
||||
let first = model.titles.isEmpty
|
||||
let refusal = await model.addShare(name: name, url: url, password: password)
|
||||
if refusal == nil && first { onFirstShareAdded() }
|
||||
return refusal
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
@@ -74,6 +80,9 @@ struct SharesView: View {
|
||||
.fullScreenCover(isPresented: $browsing) {
|
||||
DriveBrowser()
|
||||
}
|
||||
.sheet(item: $renaming) { share in
|
||||
RenameShareSheet(share: share) { model.renameShare(id: share.id, name: $0) }
|
||||
}
|
||||
.sheet(item: $repairing) { share in
|
||||
RepairShareSheet(share: share) { url, password in
|
||||
model.repairShare(id: share.id, url: url, password: password)
|
||||
@@ -97,10 +106,30 @@ struct SharesView: View {
|
||||
extension ShareRecord: Identifiable {}
|
||||
|
||||
/// One share: its name, how it is unlocked, and its actions behind a menu.
|
||||
/// "120 files · crawled 2 h ago", or that it has not been yet.
|
||||
func crawlLine(_ share: ShareRecord, now: Int64 = Int64(Date.now.timeIntervalSince1970)) -> String {
|
||||
guard let at = share.crawledAt else { return "Not crawled yet" }
|
||||
let files = share.crawlFiles.map { $0 == 1 ? "1 file" : "\($0) files" }
|
||||
return [files, "crawled \(ago(at, now: now))"].compactMap { $0 }.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`.
|
||||
func ago(_ at: Int64, now: Int64) -> String {
|
||||
let seconds = max(now - at, 0)
|
||||
switch seconds {
|
||||
case ..<60: return "just now"
|
||||
case ..<3_600: return "\(seconds / 60) min ago"
|
||||
case ..<86_400: return "\(seconds / 3_600) h ago"
|
||||
case ..<172_800: return "1 day ago"
|
||||
default: return "\(seconds / 86_400) days ago"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShareRow: View {
|
||||
let share: ShareRecord
|
||||
let onRefresh: () -> Void
|
||||
let onRepair: () -> Void
|
||||
let onRename: () -> Void
|
||||
let onRemove: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
@@ -110,14 +139,21 @@ private struct ShareRow: View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(share.name).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||||
Text(share.fromAccount ? "Folder of your Drive"
|
||||
: share.hasCustomPassword ? "Link and custom password, stored securely" : "Public link")
|
||||
: share.hasCustomPassword ? "Link and custom password" : "Public link")
|
||||
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Text(crawlLine(share)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||
// Until a crawl succeeds: a link revoked upstream otherwise
|
||||
// just stops growing.
|
||||
if let error = share.crawlError {
|
||||
Text("Last crawl failed: \(error)").textStyle(.bodySmall).foregroundStyle(scheme.error).lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Menu {
|
||||
// One share, not the library: a link that has just had files
|
||||
// added should not cost a walk of every other one.
|
||||
Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") }
|
||||
Button(action: onRename) { Label("Rename", systemImage: "pencil") }
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either.
|
||||
if !share.fromAccount {
|
||||
@@ -136,6 +172,34 @@ private struct ShareRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new name for a share. The library, history and downloads stay.
|
||||
private struct RenameShareSheet: View {
|
||||
let share: ShareRecord
|
||||
let onRename: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name: String
|
||||
|
||||
init(share: ShareRecord, onRename: @escaping (String) -> Void) {
|
||||
self.share = share
|
||||
self.onRename = onRename
|
||||
_name = State(initialValue: share.name)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DialogSheet("Rename \(share.name)") {
|
||||
Field(label: "Name", text: $name)
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Save") {
|
||||
onRename(name.trimmingCharacters(in: .whitespaces))
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enter the link for a share the app can no longer decrypt its secret for.
|
||||
///
|
||||
/// It has to be the *same* link — a different token is a different share.
|
||||
@@ -166,24 +230,34 @@ private struct RepairShareSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a link. The form stays up while the link is opened, and with the
|
||||
/// reason under it when the link is refused, so fixing a typo is a retry
|
||||
/// rather than starting over.
|
||||
private struct AddShareSheet: View {
|
||||
let onAdd: (String, String, String?) -> Void
|
||||
/// Resolves to why the link was refused, or nil once it is added.
|
||||
let onAdd: (String, String, String?) async -> String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var name = ""
|
||||
@State private var url: String
|
||||
@State private var password = ""
|
||||
@State private var adding = false
|
||||
@State private var refusal: String?
|
||||
|
||||
init(initialUrl: String, onAdd: @escaping (String, String, String?) -> Void) {
|
||||
init(initialUrl: String, onAdd: @escaping (String, String, String?) async -> String?) {
|
||||
self.onAdd = onAdd
|
||||
_url = State(initialValue: initialUrl)
|
||||
}
|
||||
|
||||
private var ready: Bool {
|
||||
!url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DialogSheet("Add Proton Drive share") {
|
||||
Field(label: "Library name", text: $name)
|
||||
// The URL fragment is a secret, so the field is a secure one: no
|
||||
// keyboard learning, no suggestions, and paste still works.
|
||||
Field(label: "Public share URL", text: $url, secret: true)
|
||||
Field(label: "Share link", text: $url, secret: true)
|
||||
// There is no share sheet into a sideloaded app, so a link copied
|
||||
// elsewhere comes in from the clipboard.
|
||||
if url.isEmpty {
|
||||
@@ -194,16 +268,32 @@ private struct AddShareSheet: View {
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
}
|
||||
// Without one, the shared folder's own name.
|
||||
Field(label: "Name (optional)", text: $name)
|
||||
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||
if adding {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||
Text("Opening the link…")
|
||||
}
|
||||
} else if let refusal {
|
||||
Text(refusal).foregroundStyle(scheme.error)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Add") {
|
||||
onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||
dismiss()
|
||||
Button(adding ? "Adding…" : "Add") {
|
||||
adding = true
|
||||
refusal = nil
|
||||
Task {
|
||||
refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||
adding = false
|
||||
if refusal == nil { dismiss() }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || url.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!ready || adding)
|
||||
}
|
||||
.interactiveDismissDisabled(adding)
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,73 @@ extension ButtonStyle where Self == ProtonButtonStyle {
|
||||
static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) }
|
||||
}
|
||||
|
||||
/// A button with no chrome of its own — an icon, a row, a chip — that still
|
||||
/// shows it was pressed: a little smaller, with a wash behind it.
|
||||
///
|
||||
/// `.plain` shows nothing at all, and on a slow device, where what the press
|
||||
/// does can take a second to appear, a press that shows nothing reads as one
|
||||
/// that missed — and gets pressed again.
|
||||
struct PressableStyle: ButtonStyle {
|
||||
var circle = false
|
||||
/// The wash's colour; the scheme's text colour when nil. White over video.
|
||||
var wash: Color?
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
let pressed = configuration.isPressed
|
||||
let fill = (wash ?? scheme.onSurface).opacity(pressed ? 0.12 : 0)
|
||||
configuration.label
|
||||
.background {
|
||||
if circle {
|
||||
Circle().fill(fill)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: Corner.standard).fill(fill)
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 0.96 : 1)
|
||||
.animation(.easeOut(duration: 0.1), value: pressed)
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == PressableStyle {
|
||||
static var pressable: PressableStyle { PressableStyle() }
|
||||
static var pressableIcon: PressableStyle { PressableStyle(circle: true) }
|
||||
}
|
||||
|
||||
/// A tap and a long press on something that is not a button — a poster, a
|
||||
/// card, an episode row — showing the press the moment the finger lands, as
|
||||
/// `PressableStyle` does for a button.
|
||||
private struct TapAndHold: ViewModifier {
|
||||
let onTap: () -> Void
|
||||
let onHold: (() -> Void)?
|
||||
@State private var pressed = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.scaleEffect(pressed ? 0.96 : 1)
|
||||
.opacity(pressed ? 0.8 : 1)
|
||||
.animation(.easeOut(duration: 0.1), value: pressed)
|
||||
.onTapGesture(perform: onTap)
|
||||
.onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 })
|
||||
// One element to VoiceOver, a button, with the long press as an
|
||||
// action: a gesture is not something it can perform.
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityAction(.default, onTap)
|
||||
.accessibilityActions {
|
||||
if let onHold { Button("More", action: onHold) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// `onTapGesture`, plus `onLongPressGesture` when `onHold` is given, with
|
||||
/// the press drawn.
|
||||
func tapAndHold(_ onTap: @escaping () -> Void, onHold: (() -> Void)? = nil) -> some View {
|
||||
modifier(TapAndHold(onTap: onTap, onHold: onHold))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far along something is, drawn in the accent.
|
||||
///
|
||||
/// `solid` is for the one bar that is deliberately not the accent: a download in
|
||||
|
||||
@@ -33,6 +33,13 @@ final class AppearanceState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the stored choice again, after sync rewrote it.
|
||||
func reload() async {
|
||||
if let read = try? await Task.detached(operation: { try NativeRuntime.blockingEngine().palette() }).value {
|
||||
palette = read
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaint immediately, before the write that stores the choice lands.
|
||||
func apply(_ palette: PaletteRecord) {
|
||||
self.palette = palette
|
||||
|
||||
@@ -15,6 +15,7 @@ struct TitleView: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showMatch = false
|
||||
@State private var downloading: DownloadRequest?
|
||||
@State private var overviewOpen = false
|
||||
@State private var seasonIndex: Int
|
||||
// Back by an edge swipe: the page shrinks towards the library as the
|
||||
@@ -35,14 +36,16 @@ struct TitleView: View {
|
||||
self.franchise = franchise
|
||||
self.onOpenTitle = onOpenTitle
|
||||
// One season on screen at a time, opened on the one Play would land in.
|
||||
let upNext = title.playlist.indices.contains(nextUpIndex(title.playlist)) ? title.playlist[nextUpIndex(title.playlist)] : nil
|
||||
let playlist = title.playlist
|
||||
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
|
||||
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
|
||||
_seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Display order across seasons: what previous/next and autoplay walk.
|
||||
let playlist = title.playlist
|
||||
let nextUp = nextUpIndex(playlist)
|
||||
let nextUp = nextUpIndex(playlist, continueIndex: title.continueIndex)
|
||||
let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil
|
||||
let season = title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex] : nil
|
||||
let downloads = DownloadCoordinator.shared.records
|
||||
@@ -69,7 +72,11 @@ struct TitleView: View {
|
||||
position: position,
|
||||
download: downloads.first { $0.shareId == episode.shareId && $0.linkId == episode.linkId },
|
||||
onPlay: { onPlay(playlist, playlist.firstIndex { $0.linkId == episode.linkId } ?? 0) },
|
||||
onSetWatched: { model.setWatched(episode, $0) }
|
||||
onSetWatched: { model.setWatched(episode, $0) },
|
||||
onWatchedUpTo: {
|
||||
let upTo = playlist.firstIndex { $0.linkId == episode.linkId } ?? 0
|
||||
model.markEpisodes(Array(playlist.prefix(upTo + 1)), true, "\(title.displayName) up to \(episode.label)")
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, side)
|
||||
}
|
||||
@@ -97,13 +104,14 @@ struct TitleView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.confirmingDownload($downloading)
|
||||
.sheet(isPresented: $showMatch) {
|
||||
ChangeMatchSheet(title: title) { showMatch = false }
|
||||
}
|
||||
.onChange(of: title.key) {
|
||||
overviewOpen = false
|
||||
let playlist = title.playlist
|
||||
let upNext = playlist.indices.contains(nextUpIndex(playlist)) ? playlist[nextUpIndex(playlist)] : nil
|
||||
let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil
|
||||
seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0
|
||||
}
|
||||
}
|
||||
@@ -176,7 +184,7 @@ struct TitleView: View {
|
||||
if upNext?.resumeAt != nil || title.watchedCount > 0 {
|
||||
TitleAction(icon: "arrow.counterclockwise", label: "Start over") { onPlay(playlist, 0) }
|
||||
}
|
||||
TitleAction(icon: "arrow.down.circle", label: "Download") { DownloadCoordinator.shared.enqueue(playlist) }
|
||||
TitleAction(icon: "arrow.down.circle", label: "Download") { downloading = download(playlist, title.displayName) }
|
||||
TitleAction(icon: "pencil", label: "Match") { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes to
|
||||
// check that the thing the app matched is what they have.
|
||||
@@ -231,7 +239,7 @@ struct TitleView: View {
|
||||
LazyHStack(alignment: .top, spacing: 12) {
|
||||
ForEach(franchise, id: \.key) { member in
|
||||
FranchiseTile(title: member, here: member.key == title.key)
|
||||
.onTapGesture { if member.key != title.key { onOpenTitle(member) } }
|
||||
.tapAndHold { if member.key != title.key { onOpenTitle(member) } }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, side + 16)
|
||||
@@ -244,14 +252,21 @@ struct TitleView: View {
|
||||
HStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(title.seasons.enumerated()), id: \.element.label) { index, entry in
|
||||
// By position: two seasons can share a label.
|
||||
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
|
||||
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let season {
|
||||
// For someone joining the show partway, who would otherwise
|
||||
// tick every earlier episode one by one.
|
||||
let seen = season.episodes.allSatisfy(\.watched)
|
||||
IconButton(seen ? "checkmark.circle.badge.xmark" : "checkmark.circle", "Mark \(season.label) \(seen ? "unwatched" : "watched")") {
|
||||
model.markEpisodes(season.episodes, !seen, "\(title.displayName) \(season.label)")
|
||||
}
|
||||
IconButton("arrow.down.circle", "Download \(season.label)") {
|
||||
DownloadCoordinator.shared.enqueue(season.episodes)
|
||||
downloading = download(season.episodes, "\(title.displayName) \(season.label)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,7 +297,7 @@ struct FilterChip: View {
|
||||
if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +356,7 @@ private struct TitleAction: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.pressable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +397,11 @@ private struct EpisodeRow: View {
|
||||
let download: RetainedDownload?
|
||||
let onPlay: () -> Void
|
||||
let onSetWatched: (Bool) -> Void
|
||||
/// Everything up to and including this one, for someone joining a show
|
||||
/// partway.
|
||||
let onWatchedUpTo: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var holding = false
|
||||
|
||||
private var subline: String {
|
||||
let size: String? = episode.size.map { formatBytes($0) }
|
||||
@@ -443,13 +462,17 @@ private struct EpisodeRow: View {
|
||||
if let active = download, active.total > 0, !episode.offline {
|
||||
AccentProgress(progress: Double(active.downloaded) / Double(active.total), solid: scheme.tertiary)
|
||||
.padding(.top, 8)
|
||||
Text(downloadStatusLine(active)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Text(downloadStatusLine(active, waitingFor: DownloadCoordinator.shared.queueReason)).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onPlay)
|
||||
.tapAndHold(onPlay) { holding = true }
|
||||
.confirmationDialog(episode.heading(position), isPresented: $holding) {
|
||||
Button("Mark watched up to here", action: onWatchedUpTo)
|
||||
}
|
||||
.accessibilityAction(named: "Mark watched up to here", onWatchedUpTo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +488,11 @@ struct ChangeMatchSheet: View {
|
||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||
@State private var choosing = false
|
||||
@State private var options: [MatchRecord] = []
|
||||
/// Said here rather than in the snackbar, which this sheet covers.
|
||||
@State private var failure: String?
|
||||
@State private var searched = false
|
||||
@State private var forgetting = false
|
||||
@State private var confirmForget = false
|
||||
|
||||
init(title: TitleRecord, onDone: @escaping () -> Void) {
|
||||
self.title = title
|
||||
@@ -481,7 +509,17 @@ struct ChangeMatchSheet: View {
|
||||
.buttonStyle(.accent)
|
||||
.disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching)
|
||||
if choosing {
|
||||
Text("Fetching its episodes…").textStyle(.bodySmall).padding(.top, 8)
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||
Text("Fetching its episodes…").textStyle(.bodySmall)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
if let failure {
|
||||
Text(failure).foregroundStyle(scheme.error)
|
||||
}
|
||||
if !searching, options.isEmpty, searched {
|
||||
Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall)
|
||||
}
|
||||
ForEach(options, id: \.remoteId) { option in
|
||||
Button { choose(option) } label: {
|
||||
@@ -497,43 +535,60 @@ struct ChangeMatchSheet: View {
|
||||
.disabled(choosing)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Forget match") {
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||
changed()
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
Button(forgetting ? "Forgetting…" : "Forget match") { confirmForget = true }
|
||||
.buttonStyle(.quiet)
|
||||
.disabled(forgetting || choosing)
|
||||
Button("Close", action: onDone).buttonStyle(.tonal)
|
||||
}
|
||||
// Asked, because a match chosen by hand is work the next look-up does
|
||||
// not redo: it decides again on its own.
|
||||
.alert("Forget this match?", isPresented: $confirmForget) {
|
||||
Button("Forget", role: .destructive, action: forget)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Its poster, description and episode names go, and the next look-up matches it again on its own. A match you chose by hand is lost.")
|
||||
}
|
||||
}
|
||||
|
||||
private func forget() {
|
||||
forgetting = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||
changed()
|
||||
} catch {
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
forgetting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func search() {
|
||||
let term = term
|
||||
guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return }
|
||||
searching = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term)
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
searching = false
|
||||
searched = true
|
||||
}
|
||||
}
|
||||
|
||||
private func choose(_ option: MatchRecord) {
|
||||
choosing = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option)
|
||||
changed()
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
choosing = false
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ private func title(_ key: String, _ episodes: [EpisodeRecord]) -> TitleRecord {
|
||||
manualMatch: false, seasons: [SeasonRecord(number: 1, label: "Season 1", episodes: episodes)],
|
||||
displayName: key, wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
|
||||
directors: [], tags: [], airing: false, nextEpisode: nil, nextAiringAt: nil,
|
||||
franchise: []
|
||||
franchise: [], continueIndex: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ final class IncomingShareLinkTests: XCTestCase {
|
||||
final class AccountUiTests: XCTestCase {
|
||||
func testTheSyncLineSaysHowLongAgoAndWhatCameIn() {
|
||||
let zero = Date(timeIntervalSince1970: 0)
|
||||
XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Watch history syncs with your Drive")
|
||||
XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Library, settings and watch history sync through your Drive")
|
||||
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(30)), "Synced just now")
|
||||
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago")
|
||||
XCTAssertEqual(
|
||||
@@ -64,7 +64,7 @@ final class AccountUiTests: XCTestCase {
|
||||
let zero = Date(timeIntervalSince1970: 0)
|
||||
XCTAssertEqual(
|
||||
syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero),
|
||||
"Watch history did not sync: offline"
|
||||
"Did not sync: offline"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# builds from the registry alone.
|
||||
|
||||
pkgname=proton-stream
|
||||
pkgver=1.3.0
|
||||
pkgver=1.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -604,23 +604,6 @@ for _name, _what, _method in (
|
||||
):
|
||||
REGISTRY.append(Case(name=_name, what=_what, run=_live(_method), needs_share=True))
|
||||
|
||||
# Still unimplemented, and the only case left that is. No longer blocked on the
|
||||
# app: B40 landed, so there is correct behaviour to assert. What is missing is a
|
||||
# driver — every live case here reads the engine and libmpv directly, and this
|
||||
# one needs the activity with the player composed on screen before PiP means
|
||||
# anything.
|
||||
REGISTRY.append(
|
||||
Case(
|
||||
name="picture-in-picture",
|
||||
what="entering PiP keeps playing and shows only video",
|
||||
run=_pending(
|
||||
"needs an activity-level driver: the live suite exercises the engine "
|
||||
"and libmpv, not the composition, and PiP is a property of the window"
|
||||
),
|
||||
needs_share=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# runner
|
||||
|
||||