Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7f057afe | ||
|
|
b1ea076ea3 | ||
|
|
8d615579e2 | ||
|
|
cafd969567 | ||
|
|
69981e3b45 | ||
|
|
cbe0f7166b | ||
|
|
f0402e74f9 | ||
|
|
d4e9628199 | ||
|
|
4b9cbd26bc | ||
|
|
588a7fcb09 | ||
|
|
e3e855a02d | ||
|
|
7790ae9cbb | ||
|
|
f0fe90f0fe | ||
|
|
51454b5a6e | ||
|
|
c44b04cf4a | ||
|
|
85bc975f5f | ||
|
|
4f130d1d6e | ||
|
|
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 | ||
|
|
dc70cafbb7 | ||
|
|
a28135cd51 | ||
|
|
4b9c01d52d | ||
|
|
3356602951 | ||
|
|
537217e70a | ||
|
|
ed05a1e723 |
No files matched your search
@@ -0,0 +1,71 @@
|
|||||||
|
name: iOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'ios/**'
|
||||||
|
- 'android/app/src/main/res/font/**'
|
||||||
|
- 'android/app/src/main/assets/licenses/**'
|
||||||
|
- 'crates/pstr-android/**'
|
||||||
|
- 'crates/pstr-core/**'
|
||||||
|
- 'crates/pstr-meta/**'
|
||||||
|
- 'crates/pstr-stream/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'scripts/build-ios.sh'
|
||||||
|
- '.github/workflows/ios.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Unit tests and unsigned .ipa
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||||
|
|
||||||
|
- name: Restore the Rust target tree
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: brew install xcodegen
|
||||||
|
|
||||||
|
- name: Bindings
|
||||||
|
run: scripts/build-ios.sh bindings
|
||||||
|
|
||||||
|
- name: Unit tests on the simulator
|
||||||
|
run: scripts/build-ios.sh test
|
||||||
|
|
||||||
|
# SideStore and AltStore re-sign whatever they install with the viewer's
|
||||||
|
# own Apple ID, so the release artifact is unsigned.
|
||||||
|
- name: Unsigned .ipa
|
||||||
|
run: |
|
||||||
|
scripts/build-ios.sh rust
|
||||||
|
scripts/build-ios.sh ipa
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: dist/*.ipa
|
||||||
|
if-no-files-found: error
|
||||||
@@ -150,10 +150,17 @@ jobs:
|
|||||||
signed_release: true
|
signed_release: true
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
|
||||||
|
# Apple ID when they install it.
|
||||||
|
ios:
|
||||||
|
name: iOS
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/ios.yml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows, android]
|
needs: [linux, windows, android, ios]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -172,6 +179,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: android
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -11,6 +11,110 @@ dependencies = [
|
|||||||
"uuid",
|
"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]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -360,6 +464,20 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "async-io"
|
name = "async-io"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -482,6 +600,43 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
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]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -2017,6 +2172,7 @@ version = "0.35.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db"
|
checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"accesskit_winit",
|
||||||
"arboard",
|
"arboard",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -5122,7 +5278,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5142,7 +5298,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5176,7 +5332,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5195,7 +5351,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5215,7 +5371,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -5228,7 +5384,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -5243,7 +5399,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -5870,7 +6026,7 @@ dependencies = [
|
|||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7229,6 +7385,11 @@ name = "uuid"
|
|||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
@@ -8544,9 +8705,68 @@ dependencies = [
|
|||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"xdg-home",
|
"xdg-home",
|
||||||
"zbus_macros",
|
"zbus_macros 4.4.0",
|
||||||
"zbus_names",
|
"zbus_names 3.0.0",
|
||||||
"zvariant",
|
"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]]
|
[[package]]
|
||||||
@@ -8559,7 +8779,22 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
@@ -8570,7 +8805,39 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
@@ -8704,7 +8971,22 @@ dependencies = [
|
|||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
@@ -8717,7 +8999,20 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
@@ -8730,3 +9025,16 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"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]
|
[workspace.package]
|
||||||
version = "1.2.0"
|
version = "1.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -73,8 +73,10 @@ keyring = { version = "3" }
|
|||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
# 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
|
# `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 = [
|
eframe = { version = "0.35", default-features = false, features = [
|
||||||
|
"accesskit",
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
"glow",
|
"glow",
|
||||||
"wayland",
|
"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
|
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||||
tests) passes, and Android lint runs in CI.
|
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)
|
## 0. CI (done)
|
||||||
|
|
||||||
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ layer and the player are done and verified against a real share, and the app has
|
|||||||
a library wall, a page per title with seasons and episodes,
|
a library wall, a page per title with seasons and episodes,
|
||||||
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
||||||
button for openings and endings, next/previous episode with an "up next"
|
button for openings and endings, next/previous episode with an "up next"
|
||||||
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
|
countdown, subtitle files beside the video, optional metadata, themes and share
|
||||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
management. What is left is mostly running the macOS packaging on a real Mac. See
|
||||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
`docs/DEVELOPMENT.md`.
|
||||||
|
|
||||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
||||||
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
||||||
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||||
|
|
||||||
|
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
|
||||||
|
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
|
||||||
|
[`docs/IOS.md`](docs/IOS.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
@@ -73,7 +77,7 @@ against the SDK's own range download.
|
|||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
||||||
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -99,6 +103,9 @@ Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
|||||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||||
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
||||||
|
|
||||||
|
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
|
||||||
|
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
- The share URL fragment **is** the decryption password. It and any custom
|
- The share URL fragment **is** the decryption password. It and any custom
|
||||||
@@ -121,9 +128,10 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
The shared Rust crates and desktop application are MIT licensed. The Android and
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
iOS applications and their combined native distributions are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
|
||||||
|
the bundled-component notices.
|
||||||
The desktop application embeds the Inter typeface, under the SIL Open Font
|
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
and the Phosphor icon font, under MIT.
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Android TV: listed in its launcher, and installable without a touch
|
||||||
|
screen. Neither is required, so phones are offered the same build.
|
||||||
|
-->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ProtonStreamApplication"
|
android:name=".ProtonStreamApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -14,6 +21,7 @@
|
|||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
tools:targetApi="33"
|
tools:targetApi="33"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ProtonStream">
|
android:theme="@style/Theme.ProtonStream">
|
||||||
@@ -35,11 +43,11 @@
|
|||||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:supportsPictureInPicture="true"
|
|
||||||
android:resizeableActivity="true">
|
android:resizeableActivity="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!--
|
<!--
|
||||||
Proton Drive public links. autoVerify="false", said explicitly:
|
Proton Drive public links. autoVerify="false", said explicitly:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ extern "C" {
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ struct PlaybackState {
|
|||||||
/// and must not be attributed to the file being loaded.
|
/// and must not be attributed to the file being loaded.
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
/// The picture's display size, once mpv has decoded enough to know it.
|
/// 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_width = 0.0;
|
||||||
double video_height = 0.0;
|
double video_height = 0.0;
|
||||||
/// Which loaded file this state describes. Bumped by every load, so a
|
/// Which loaded file this state describes. Bumped by every load, so a
|
||||||
@@ -210,6 +211,17 @@ struct PlaybackState {
|
|||||||
/// episode one — which is the difference between resuming an episode where
|
/// episode one — which is the difference between resuming an episode where
|
||||||
/// the viewer left it and resuming it where the *previous* one ended.
|
/// the viewer left it and resuming it where the *previous* one ended.
|
||||||
int64_t generation = 0;
|
int64_t generation = 0;
|
||||||
|
/// mpv has loaded this generation's file and made its own track pick,
|
||||||
|
/// which is the first moment the pick can be second-guessed.
|
||||||
|
bool loaded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A subtitle file published for mpv, and what to call it.
|
||||||
|
struct Subtitle {
|
||||||
|
uint64_t handle = 0;
|
||||||
|
std::string title;
|
||||||
|
std::string language;
|
||||||
|
bool select = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Player {
|
class Player {
|
||||||
@@ -338,7 +350,10 @@ class Player {
|
|||||||
state_.generation = generation;
|
state_.generation = generation;
|
||||||
state_.loading = true;
|
state_.loading = true;
|
||||||
error_.clear();
|
error_.clear();
|
||||||
|
for (const auto &subtitle : pending_subtitles_) released_.push_back(subtitle.handle);
|
||||||
|
pending_subtitles_.clear();
|
||||||
}
|
}
|
||||||
|
flush_released();
|
||||||
const std::string url = "pstr://" + std::to_string(handle);
|
const std::string url = "pstr://" + std::to_string(handle);
|
||||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||||
if (mpv_command(mpv_, command) < 0) {
|
if (mpv_command(mpv_, command) < 0) {
|
||||||
@@ -372,6 +387,27 @@ class Player {
|
|||||||
mpv_command(mpv_, command);
|
mpv_command(mpv_, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A subtitle file beside the file of load `generation`, added once mpv
|
||||||
|
/// has that file loaded: an external track belongs to the file open at
|
||||||
|
/// the time. Handed over for a file already replaced, it is released
|
||||||
|
/// instead. `select` turns it on, but only when the file has no subtitle
|
||||||
|
/// of its own playing.
|
||||||
|
void add_subtitle(int64_t generation, Subtitle subtitle) {
|
||||||
|
bool now = false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
if (generation != state_.generation) {
|
||||||
|
released_.push_back(subtitle.handle);
|
||||||
|
} else if (loaded_generation_ == generation) {
|
||||||
|
now = true;
|
||||||
|
} else {
|
||||||
|
pending_subtitles_.push_back(std::move(subtitle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush_released();
|
||||||
|
if (now) attach_subtitle(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackState state() const {
|
PlaybackState state() const {
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
return state_;
|
return state_;
|
||||||
@@ -395,6 +431,8 @@ class Player {
|
|||||||
char *lang_raw = nullptr;
|
char *lang_raw = nullptr;
|
||||||
char *title_raw = nullptr;
|
char *title_raw = nullptr;
|
||||||
int selected = 0;
|
int selected = 0;
|
||||||
|
int is_default = 0;
|
||||||
|
int forced = 0;
|
||||||
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
||||||
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||||
MpvString type(type_raw);
|
MpvString type(type_raw);
|
||||||
@@ -403,10 +441,14 @@ class Player {
|
|||||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||||
MpvString title(title_raw);
|
MpvString title(title_raw);
|
||||||
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
||||||
|
mpv_get_property(mpv_, (base + "default").c_str(), MPV_FORMAT_FLAG, &is_default);
|
||||||
|
mpv_get_property(mpv_, (base + "forced").c_str(), MPV_FORMAT_FLAG, &forced);
|
||||||
if (i) json += ',';
|
if (i) json += ',';
|
||||||
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||||
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
||||||
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}";
|
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") +
|
||||||
|
",\"default\":" + (is_default ? "true" : "false") +
|
||||||
|
",\"forced\":" + (forced ? "true" : "false") + "}";
|
||||||
}
|
}
|
||||||
return json + ']';
|
return json + ']';
|
||||||
}
|
}
|
||||||
@@ -434,6 +476,35 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void attach_subtitle(const Subtitle &subtitle) {
|
||||||
|
bool showing = false;
|
||||||
|
if (char *sid = mpv_get_property_string(mpv_, "sid")) {
|
||||||
|
showing = std::strcmp(sid, "no") != 0;
|
||||||
|
mpv_free(sid);
|
||||||
|
}
|
||||||
|
const std::string url = "pstr://" + std::to_string(subtitle.handle);
|
||||||
|
std::vector<const char *> command{
|
||||||
|
"sub-add", url.c_str(), subtitle.select && !showing ? "select" : "auto",
|
||||||
|
subtitle.title.c_str()};
|
||||||
|
if (!subtitle.language.empty()) command.push_back(subtitle.language.c_str());
|
||||||
|
command.push_back(nullptr);
|
||||||
|
// On failure mpv may never have opened it, so nothing will close it.
|
||||||
|
// Releasing one it did open and close is a no-op.
|
||||||
|
if (mpv_command(mpv_, command.data()) < 0) pstr_android_stream_release(subtitle.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the subtitle streams queued under `state_mutex_`, outside it:
|
||||||
|
/// a release runs Rust destructors, which must not run under a lock a
|
||||||
|
/// reader of the state may be waiting on.
|
||||||
|
void flush_released() {
|
||||||
|
std::vector<uint64_t> handles;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
handles.swap(released_);
|
||||||
|
}
|
||||||
|
for (const uint64_t handle : handles) pstr_android_stream_release(handle);
|
||||||
|
}
|
||||||
|
|
||||||
struct MpvFree {
|
struct MpvFree {
|
||||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||||
};
|
};
|
||||||
@@ -534,6 +605,17 @@ class Player {
|
|||||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||||
const double start = pending_start_.exchange(0.0);
|
const double start = pending_start_.exchange(0.0);
|
||||||
if (start > 0.0) set_double("time-pos", start);
|
if (start > 0.0) set_double("time-pos", start);
|
||||||
|
std::vector<Subtitle> ready;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
// Mid-load, this is the outgoing file finishing its load.
|
||||||
|
if (!state_.loading) {
|
||||||
|
loaded_generation_ = state_.generation;
|
||||||
|
state_.loaded = true;
|
||||||
|
ready.swap(pending_subtitles_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &subtitle : ready) attach_subtitle(subtitle);
|
||||||
}
|
}
|
||||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||||
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||||
@@ -757,6 +839,12 @@ class Player {
|
|||||||
bool surface_changed_ = false;
|
bool surface_changed_ = false;
|
||||||
std::atomic<bool> frame_ready_{false};
|
std::atomic<bool> frame_ready_{false};
|
||||||
std::atomic<double> pending_start_{0.0};
|
std::atomic<double> pending_start_{0.0};
|
||||||
|
/// The load generation whose file mpv has loaded, under `state_mutex_`.
|
||||||
|
int64_t loaded_generation_ = -1;
|
||||||
|
/// Subtitles handed over before their file loaded, under `state_mutex_`.
|
||||||
|
std::vector<Subtitle> pending_subtitles_;
|
||||||
|
/// Streams to release once `state_mutex_` is let go.
|
||||||
|
std::vector<uint64_t> released_;
|
||||||
};
|
};
|
||||||
|
|
||||||
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
||||||
@@ -850,6 +938,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
|||||||
: JNI_FALSE);
|
: JNI_FALSE);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAddSubtitle(JNIEnv *env, jobject, jlong handle, jlong generation, jlong stream, jstring title, jstring language, jboolean select) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
Subtitle subtitle{static_cast<uint64_t>(stream), {}, {}, select == JNI_TRUE};
|
||||||
|
auto *p = from(handle);
|
||||||
|
if (!p || !utf8(env, title, subtitle.title) || !utf8(env, language, subtitle.language)) {
|
||||||
|
pstr_android_stream_release(subtitle.handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p->add_subtitle(generation, std::move(subtitle));
|
||||||
|
});
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||||
}
|
}
|
||||||
@@ -881,8 +980,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
|||||||
state.video_width, state.video_height,
|
state.video_width, state.video_height,
|
||||||
static_cast<jdouble>(state.end_reason),
|
static_cast<jdouble>(state.end_reason),
|
||||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||||
state.seeking ? 1.0 : 0.0, state.cached_until};
|
state.seeking ? 1.0 : 0.0, state.cached_until,
|
||||||
constexpr jsize kFields = 14;
|
state.loaded ? 1.0 : 0.0};
|
||||||
|
constexpr jsize kFields = 15;
|
||||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||||
|
|||||||
@@ -1,54 +1,38 @@
|
|||||||
package io.narl.protonstream
|
package io.narl.protonstream
|
||||||
|
|
||||||
import android.Manifest
|
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.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Rational
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.playback.MpvPlaybackState
|
|
||||||
import io.narl.protonstream.playback.NativeMpvHost
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlaybackService
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||||
import io.narl.protonstream.sync.WatchSyncWorker
|
import io.narl.protonstream.sync.WatchSyncWorker
|
||||||
import io.narl.protonstream.ui.ProtonStreamApp
|
import io.narl.protonstream.ui.ProtonStreamApp
|
||||||
import io.narl.protonstream.ui.theme.AppearanceState
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
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 kotlinx.coroutines.launch
|
||||||
import uniffi.pstr_android.PaletteRecord
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
private val inPictureInPicture = mutableStateOf(false)
|
|
||||||
|
|
||||||
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||||
private val incomingShareLink = mutableStateOf<String?>(null)
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||||
@@ -69,30 +53,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(
|
private val notificationPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Before anything draws. The palette decides the window's own
|
// Before anything draws. The palette decides the window's own
|
||||||
@@ -116,34 +80,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
playbackConnection,
|
playbackConnection,
|
||||||
Context.BIND_AUTO_CREATE,
|
Context.BIND_AUTO_CREATE,
|
||||||
)
|
)
|
||||||
ContextCompat.registerReceiver(
|
NewEpisodesWorker.schedule(this)
|
||||||
this,
|
|
||||||
pictureInPictureControls,
|
|
||||||
IntentFilter(ACTION_PIP_CONTROL),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
|
||||||
// building the params inside onUserLeaveHint is too late for the swipe
|
|
||||||
// home that gesture navigation sends, which never calls it. Re-register
|
|
||||||
// whenever the picture's shape or the transport's does.
|
|
||||||
lifecycleScope.launch {
|
|
||||||
snapshotFlow { playerHost.value }
|
|
||||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
|
||||||
.map { state -> PictureInPictureShape.of(state) }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
|
||||||
}
|
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(
|
ProtonStreamApp(
|
||||||
playerHost.value,
|
playerHost.value,
|
||||||
inPictureInPicture.value,
|
|
||||||
shareLink = incomingShareLink.value,
|
shareLink = incomingShareLink.value,
|
||||||
onShareLinkTaken = { incomingShareLink.value = null },
|
onShareLinkTaken = { incomingShareLink.value = null },
|
||||||
)
|
)
|
||||||
@@ -178,27 +125,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
intent.shareLink()?.let { incomingShareLink.value = it }
|
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() {
|
override fun onDestroy() {
|
||||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
|
||||||
if (bound) {
|
if (bound) {
|
||||||
unbindService(playbackConnection)
|
unbindService(playbackConnection)
|
||||||
bound = false
|
bound = false
|
||||||
@@ -213,87 +140,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||||
if (
|
if (
|
||||||
!isChangingConfigurations &&
|
!isChangingConfigurations &&
|
||||||
!isInPictureInPictureMode &&
|
|
||||||
!SettingsStore(this).backgroundAudio
|
!SettingsStore(this).backgroundAudio
|
||||||
) {
|
) {
|
||||||
playerHost.value?.stop()
|
playerHost.value?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
|
||||||
private data class PictureInPictureShape(
|
|
||||||
val active: Boolean,
|
|
||||||
val paused: Boolean,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
|
||||||
active = state != null && state.duration > 0.0 && !state.ended,
|
|
||||||
paused = state?.paused != false,
|
|
||||||
width = state?.videoWidth?.toInt() ?: 0,
|
|
||||||
height = state?.videoHeight?.toInt() ?: 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAspectRatio(aspectRatio(width, height))
|
|
||||||
.setAutoEnterEnabled(active)
|
|
||||||
.setSeamlessResizeEnabled(false)
|
|
||||||
// The player owns the whole window, so the picture's rectangle is
|
|
||||||
// the window's. Without it the transition into PiP cross-fades from
|
|
||||||
// a screenshot instead of shrinking the video that is already there.
|
|
||||||
.setSourceRectHint(windowBounds())
|
|
||||||
.setActions(
|
|
||||||
listOf(
|
|
||||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
|
||||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
|
||||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
|
||||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
|
||||||
|
|
||||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(this, icon),
|
|
||||||
label,
|
|
||||||
label,
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
this,
|
|
||||||
requestCode,
|
|
||||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
|
||||||
const val EXTRA_CONTROL = "control"
|
|
||||||
const val CONTROL_PLAY = "play"
|
|
||||||
const val CONTROL_PAUSE = "pause"
|
|
||||||
const val CONTROL_NEXT = "next"
|
|
||||||
const val CONTROL_PREVIOUS = "previous"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
|
||||||
* and refuses the request outright rather than clamping it — so a
|
|
||||||
* 2.40:1 scope release would take PiP away entirely.
|
|
||||||
*/
|
|
||||||
fun aspectRatio(width: Int, height: Int): Rational {
|
|
||||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
|
||||||
val ratio = width.toDouble() / height
|
|
||||||
return when {
|
|
||||||
ratio > MAX_RATIO -> Rational(239, 100)
|
|
||||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
|
||||||
else -> Rational(width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const val MAX_RATIO = 2.39
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,15 @@ package io.narl.protonstream
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
|
||||||
class ProtonStreamApplication : Application() {
|
class ProtonStreamApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NativeRuntime.initialize(this)
|
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
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
@@ -25,12 +29,27 @@ class OfflineDownloadWorker(
|
|||||||
) : CoroutineWorker(appContext, parameters) {
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo(null))
|
|
||||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||||
val store = DownloadStateStore(applicationContext)
|
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
|
// Queued, not running: the engine lets two downloads transfer at once
|
||||||
// and holds the rest back, and only the first progress report means
|
// and holds the rest back, and only the first progress report means
|
||||||
// this one has a slot.
|
// this one has a slot.
|
||||||
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
|
|||||||
reportedAt = now
|
reportedAt = now
|
||||||
reportedPercent = percent
|
reportedPercent = percent
|
||||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||||
setForegroundAsync(createForegroundInfo(percent))
|
setForegroundAsync(createForegroundInfo(label, percent))
|
||||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||||
// this read-modify-write used to put RUNNING back over the
|
// this read-modify-write used to put RUNNING back over the
|
||||||
// viewer's PAUSED and then report the download as cancelled.
|
// 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)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle(
|
.setContentTitle(label)
|
||||||
if (progress == null) "Waiting to download" else "Making episode available offline",
|
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
|
||||||
)
|
|
||||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setGroup(GROUP)
|
||||||
.build()
|
.build()
|
||||||
return ForegroundInfo(
|
return ForegroundInfo(
|
||||||
stableNotificationId(id),
|
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()
|
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -149,6 +203,10 @@ class OfflineDownloadWorker(
|
|||||||
const val KEY_TOTAL = "total"
|
const val KEY_TOTAL = "total"
|
||||||
private const val MAX_RETRIES = 3
|
private const val MAX_RETRIES = 3
|
||||||
private const val CHANNEL_ID = "offline-downloads"
|
private const val CHANNEL_ID = "offline-downloads"
|
||||||
|
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. */
|
/** Shortest gap between two identical-looking progress reports. */
|
||||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||||
@@ -186,4 +244,4 @@ internal class TransferRate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun stableNotificationId(workId: UUID): Int =
|
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)
|
||||||
@@ -19,7 +19,10 @@ import java.util.concurrent.locks.ReentrantLock
|
|||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
import uniffi.pstr_android.ChapterPlan
|
import uniffi.pstr_android.ChapterPlan
|
||||||
import uniffi.pstr_android.ChapterRecord
|
import uniffi.pstr_android.ChapterRecord
|
||||||
|
import uniffi.pstr_android.SubtitleFilesRecord
|
||||||
|
import uniffi.pstr_android.SubtitleTrackRecord
|
||||||
import uniffi.pstr_android.chapterPlan
|
import uniffi.pstr_android.chapterPlan
|
||||||
|
import uniffi.pstr_android.fullSubtitlesInstead
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||||
@@ -85,9 +88,9 @@ data class MpvPlaybackState(
|
|||||||
/**
|
/**
|
||||||
* What is on screen, for the surfaces that are not the player.
|
* 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 media session and its notification have to name the episode, and
|
||||||
* the episode, and none of them can reach the composition that knows it. The
|
* neither can reach the composition that knows it. The player publishes this
|
||||||
* player publishes this once per episode and they read it.
|
* once per episode and they read it.
|
||||||
*/
|
*/
|
||||||
data class NowPlaying(
|
data class NowPlaying(
|
||||||
val media: String,
|
val media: String,
|
||||||
@@ -107,6 +110,10 @@ data class MpvTrack(
|
|||||||
val language: String,
|
val language: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
|
/** The container marks it as the default choice. */
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
/** The container marks it as forced: on-screen text only, by convention. */
|
||||||
|
val forced: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||||
@@ -128,14 +135,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
@Volatile private var currentMedia: String? = null
|
@Volatile private var currentMedia: String? = null
|
||||||
@Volatile private var currentGeneration: Double = -1.0
|
@Volatile private var currentGeneration: Double = -1.0
|
||||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||||
|
/** The load generation whose subtitle pick has been looked at. */
|
||||||
|
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||||
var onPlaybackStarted: (() -> Unit)? = null
|
var onPlaybackStarted: (() -> Unit)? = null
|
||||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||||
var onExplicitStop: (() -> Unit)? = null
|
var onExplicitStop: (() -> Unit)? = null
|
||||||
/**
|
/**
|
||||||
* Where a transport outside the player sends previous/next.
|
* Where a transport outside the player sends previous/next.
|
||||||
*
|
*
|
||||||
* The player owns which episode is open, so the notification, the lock
|
* The player owns which episode is open, so the notification and the lock
|
||||||
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
|
* screen cannot walk the playlist themselves. Both
|
||||||
* run on the main thread, which is where the player's state lives.
|
* run on the main thread, which is where the player's state lives.
|
||||||
*/
|
*/
|
||||||
var onSkipPrevious: (() -> Unit)? = null
|
var onSkipPrevious: (() -> Unit)? = null
|
||||||
@@ -245,7 +254,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
*/
|
*/
|
||||||
fun beginTransition() { mutableAdvancing.value = true }
|
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 publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||||
|
|
||||||
fun setPaused(paused: Boolean) {
|
fun setPaused(paused: Boolean) {
|
||||||
@@ -292,6 +301,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
|
||||||
|
*
|
||||||
|
* Native code holds them until mpv has the file loaded, since an external
|
||||||
|
* track belongs to the file open at the time, and releases them instead
|
||||||
|
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
|
||||||
|
* is turned on only when the file has no subtitle of its own playing;
|
||||||
|
* pass [subtitles] false to turn none on.
|
||||||
|
*/
|
||||||
|
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
|
||||||
|
val generation = currentGeneration.toLong()
|
||||||
|
val added = withOpenHandle { handle ->
|
||||||
|
files.files.forEachIndexed { index, file ->
|
||||||
|
if (currentMedia == media) {
|
||||||
|
val select = subtitles && files.preferred == index.toUInt()
|
||||||
|
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
|
||||||
|
} else {
|
||||||
|
pstrAndroidStreamRelease(file.handle.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
if (poller?.isActive == true) return
|
if (poller?.isActive == true) return
|
||||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||||
@@ -322,6 +355,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
// both of which are the main thread's.
|
// both of which are the main thread's.
|
||||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||||
refreshChapters(next)
|
refreshChapters(next)
|
||||||
|
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
|
||||||
|
subtitlesCheckedFor != currentGeneration
|
||||||
|
) {
|
||||||
|
subtitlesCheckedFor = currentGeneration
|
||||||
|
preferFullSubtitles()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||||
if (tick++ % 4 == 0) refreshTracks()
|
if (tick++ % 4 == 0) refreshTracks()
|
||||||
@@ -359,13 +398,32 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the dialogue rather than the signs and songs mpv picked by language
|
||||||
|
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
|
||||||
|
* have made, not the viewer's.
|
||||||
|
*/
|
||||||
|
private fun preferFullSubtitles() {
|
||||||
|
refreshTracks()
|
||||||
|
val subtitles = mutableTracks.value.filter { it.type == "sub" }
|
||||||
|
val id = fullSubtitlesInstead(
|
||||||
|
subtitles.map {
|
||||||
|
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
|
||||||
|
},
|
||||||
|
) ?: return
|
||||||
|
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshTracks() {
|
private fun refreshTracks() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||||
val values = JSONArray(encoded)
|
val values = JSONArray(encoded)
|
||||||
List(values.length()) { index ->
|
List(values.length()) { index ->
|
||||||
values.getJSONObject(index).run {
|
values.getJSONObject(index).run {
|
||||||
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
MpvTrack(
|
||||||
|
getLong("id"), getString("type"), getString("language"), getString("title"),
|
||||||
|
getBoolean("selected"), optBoolean("default"), optBoolean("forced"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onSuccess { mutableTracks.value = it }
|
}.onSuccess { mutableTracks.value = it }
|
||||||
@@ -414,6 +472,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
hardwareDecoding: Boolean,
|
hardwareDecoding: Boolean,
|
||||||
): Boolean
|
): Boolean
|
||||||
|
private external fun nativeAddSubtitle(
|
||||||
|
handle: Long,
|
||||||
|
generation: Long,
|
||||||
|
stream: Long,
|
||||||
|
title: String,
|
||||||
|
language: String?,
|
||||||
|
select: Boolean,
|
||||||
|
)
|
||||||
private external fun nativePause(handle: Long, paused: Boolean)
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
private external fun nativeSeek(handle: Long, position: Double)
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
private external fun nativeVolume(handle: Long, volume: Double)
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
@@ -434,7 +500,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||||
private const val FIELDS = 14
|
private const val FIELDS = 15
|
||||||
private const val GENERATION = 6
|
private const val GENERATION = 6
|
||||||
private const val WIDTH = 7
|
private const val WIDTH = 7
|
||||||
private const val HEIGHT = 8
|
private const val HEIGHT = 8
|
||||||
@@ -443,6 +509,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private const val CACHE_PERCENT = 11
|
private const val CACHE_PERCENT = 11
|
||||||
private const val SEEKING = 12
|
private const val SEEKING = 12
|
||||||
private const val CACHED_UNTIL = 13
|
private const val CACHED_UNTIL = 13
|
||||||
|
private const val LOADED = 14
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
|
|||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.narl.protonstream.MainActivity
|
import io.narl.protonstream.MainActivity
|
||||||
import io.narl.protonstream.R
|
import io.narl.protonstream.R
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import io.narl.protonstream.ui.loadArtwork
|
import io.narl.protonstream.ui.loadArtwork
|
||||||
import io.narl.protonstream.ui.loadThumbnail
|
import io.narl.protonstream.ui.loadThumbnail
|
||||||
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
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() {
|
class PlaybackService : Service() {
|
||||||
inner class PlaybackBinder : Binder() {
|
inner class PlaybackBinder : Binder() {
|
||||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
|||||||
private var notified: NotificationShape? = null
|
private var notified: NotificationShape? = null
|
||||||
/** The pending teardown, held so an episode transition can call it off. */
|
/** The pending teardown, held so an episode transition can call it off. */
|
||||||
private var retire: Job? = null
|
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. */
|
/** The episode's artwork, and the source key it was fetched for. */
|
||||||
private var artwork: Pair<String, Bitmap>? = null
|
private var artwork: Pair<String, Bitmap>? = null
|
||||||
private val binder = PlaybackBinder()
|
private val binder = PlaybackBinder()
|
||||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
mediaSession.isActive = true
|
mediaSession.isActive = true
|
||||||
}
|
}
|
||||||
onStateChanged = { updateSession(it) }
|
onStateChanged = { updateSession(it); track(it) }
|
||||||
onExplicitStop = { retirePlayback() }
|
onExplicitStop = { retirePlayback() }
|
||||||
}
|
}
|
||||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
|||||||
return START_NOT_STICKY
|
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() {
|
override fun onDestroy() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
runCatching { unregisterReceiver(becomingNoisy) }
|
runCatching { unregisterReceiver(becomingNoisy) }
|
||||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
|||||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
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) {
|
private fun updateSession(state: MpvPlaybackState) {
|
||||||
refreshSession(state, host?.nowPlaying?.value)
|
refreshSession(state, host?.nowPlaying?.value)
|
||||||
if (state.ended) scheduleRetire() else cancelRetire()
|
if (state.ended) scheduleRetire() else cancelRetire()
|
||||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
|||||||
private const val TRANSITION_GRACE_MS = 30_000L
|
private const val TRANSITION_GRACE_MS = 30_000L
|
||||||
/** How far the volume drops for a navigation prompt or a notification. */
|
/** How far the volume drops for a navigation prompt or a notification. */
|
||||||
private const val DUCK_FACTOR = 0.3
|
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. */
|
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.accessibility.AccessibilityManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -12,13 +13,13 @@ import androidx.compose.animation.fadeOut
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Speed
|
||||||
import androidx.compose.material.icons.filled.Forward30
|
import androidx.compose.material.icons.filled.Forward30
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
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.FullscreenExit
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.material.icons.filled.Subtitles
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -58,9 +62,23 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -70,6 +88,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -106,6 +125,7 @@ import uniffi.pstr_android.EpisodeRecord
|
|||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TrackPreferencesRecord
|
import uniffi.pstr_android.TrackPreferencesRecord
|
||||||
import uniffi.pstr_android.skipOffer
|
import uniffi.pstr_android.skipOffer
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** How long the controls stay up after a tap. */
|
/** How long the controls stay up after a tap. */
|
||||||
@@ -114,8 +134,17 @@ private const val CONTROLS_SECONDS = 4L
|
|||||||
/** How long the "up next" card counts down before it loads the next episode. */
|
/** How long the "up next" card counts down before it loads the next episode. */
|
||||||
private const val UP_NEXT_SECONDS = 10
|
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. */
|
/** 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. */
|
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||||
private const val MAX_VOLUME = 100f
|
private const val MAX_VOLUME = 100f
|
||||||
@@ -126,23 +155,22 @@ private const val MAX_VOLUME = 100f
|
|||||||
* Below it — a phone held upright — the transport row is already five controls
|
* 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.
|
* 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.
|
* 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
|
* Not a dialog: a dialog window has its own insets and cannot be told to draw
|
||||||
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
|
* into the display cutout. Owning the activity window is what makes
|
||||||
* activity window is what makes "fullscreen" mean the whole panel, notch
|
* "fullscreen" mean the whole panel, notch included.
|
||||||
* included.
|
|
||||||
*
|
*
|
||||||
* [episodes] is the title's episodes in display order and [index] the one that is
|
* [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
|
* 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 —
|
* 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.
|
* 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
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
@@ -150,7 +178,6 @@ fun PlayerScreen(
|
|||||||
episodes: List<EpisodeRecord>,
|
episodes: List<EpisodeRecord>,
|
||||||
index: Int,
|
index: Int,
|
||||||
host: LibmpvHost?,
|
host: LibmpvHost?,
|
||||||
inPictureInPicture: Boolean,
|
|
||||||
minimized: Boolean,
|
minimized: Boolean,
|
||||||
onIndexChange: (Int) -> Unit,
|
onIndexChange: (Int) -> Unit,
|
||||||
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
||||||
@@ -189,8 +216,16 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val key = mediaKey(episode)
|
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) }
|
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) {
|
LaunchedEffect(session, host) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -209,7 +244,7 @@ fun PlayerScreen(
|
|||||||
size = session.size(),
|
size = session.size(),
|
||||||
// A finished episode restarts rather than resuming three
|
// A finished episode restarts rather than resuming three
|
||||||
// seconds from its own credits.
|
// 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,
|
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
||||||
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
||||||
subtitles = show?.subtitles ?: global.subtitles,
|
subtitles = show?.subtitles ?: global.subtitles,
|
||||||
@@ -223,12 +258,21 @@ fun PlayerScreen(
|
|||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
SettingsStore(context).hardwareDecoding,
|
SettingsStore(context).hardwareDecoding,
|
||||||
)
|
)
|
||||||
|
|
||||||
// After the load, because mpv keeps none of the three across one.
|
// After the load, because mpv keeps none of the three across one.
|
||||||
nativeHost.setVolume(startup.volume)
|
nativeHost.setVolume(startup.volume)
|
||||||
nativeHost.setMuted(startup.muted)
|
nativeHost.setMuted(startup.muted)
|
||||||
nativeHost.setSpeed(startup.speed)
|
nativeHost.setSpeed(startup.speed)
|
||||||
session.transferToPlayer()
|
session.transferToPlayer()
|
||||||
|
// While mpv opens the episode, and off the spinner's path: the host
|
||||||
|
// holds them until it has the file.
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
val files = NativeRuntime.engine()
|
||||||
|
.openSubtitles(episode.shareId, episode.linkId, startup.subtitleLanguage)
|
||||||
|
nativeHost.addSubtitles(key, files, startup.subtitles)
|
||||||
|
}
|
||||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||||
|
opening = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||||
@@ -266,12 +310,8 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(key) {
|
// The five-second save is PlaybackService's, which outlives this screen;
|
||||||
while (true) {
|
// these are the moments only the screen sees.
|
||||||
delay(5_000)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DisposableEffect(key) { onDispose { save() } }
|
DisposableEffect(key) { onDispose { save() } }
|
||||||
|
|
||||||
val next = episodes.getOrNull(index + 1)
|
val next = episodes.getOrNull(index + 1)
|
||||||
@@ -294,9 +334,8 @@ fun PlayerScreen(
|
|||||||
onIndexChange(target)
|
onIndexChange(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the media notification, the lock screen and Picture-in-Picture show.
|
// What the media notification and the lock screen show. Neither can reach
|
||||||
// None of them can reach this composition, and all three have to name the
|
// this composition, and both have to name the episode rather than the app.
|
||||||
// episode rather than the app.
|
|
||||||
LaunchedEffect(key, index, episodes.size, title.key) {
|
LaunchedEffect(key, index, episodes.size, title.key) {
|
||||||
nativeHost.publish(
|
nativeHost.publish(
|
||||||
NowPlaying(
|
NowPlaying(
|
||||||
@@ -324,8 +363,10 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
// Autoplay fires on a clean end of file only, and only for this episode's
|
// 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.
|
// 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) {
|
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
|
// 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.
|
// difference between a broken episode and an app that silently skipped one.
|
||||||
@@ -334,14 +375,42 @@ fun PlayerScreen(
|
|||||||
playbackError = "This episode could not be played."
|
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
|
// 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.
|
// time a header turns out to be unreadable it has long since returned.
|
||||||
val problem by nativeHost.problem.collectAsState()
|
val problem by nativeHost.problem.collectAsState()
|
||||||
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
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()
|
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
|
// Auto-skip is offered once per chapter: seeking back into an opening on
|
||||||
// purpose must not be undone by the setting that skipped it.
|
// purpose must not be undone by the setting that skipped it.
|
||||||
val skipped = remember(key) { mutableSetOf<Long>() }
|
val skipped = remember(key) { mutableSetOf<Long>() }
|
||||||
@@ -352,31 +421,105 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var landscapeLocked by remember { mutableStateOf(false) }
|
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) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(showControls, state.paused) {
|
// TalkBack moves between controls by swiping, and a control that fades
|
||||||
if (showControls && !state.paused) {
|
// 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)
|
delay(CONTROLS_SECONDS * 1_000)
|
||||||
showControls = false
|
showControls = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Everything this screen draws over the picture is sized for a phone panel
|
// Which side was double-tapped, shown for a moment so the seek is seen
|
||||||
// and unreadable in a thumbnail, and Android supplies its own transport
|
// to have happened; the count makes a second tap on one side show again.
|
||||||
// there. Leaving PiP must find the controls up rather than mid-timeout.
|
var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||||
val overlays = !inPictureInPicture
|
LaunchedEffect(nudge) {
|
||||||
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
|
if (nudge != null) {
|
||||||
|
delay(NUDGE_MS)
|
||||||
|
nudge = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val current by rememberUpdatedState(state)
|
||||||
|
// Where a remote's keys land while the controls are down. Once they are
|
||||||
|
// up, the D-pad moves between their buttons as it does anywhere else.
|
||||||
|
val keys = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
|
||||||
if (minimized) return
|
if (minimized) return
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.clickable(
|
// A TV remote, a keyboard: left and right seek as a double tap
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
// does, the middle button brings the controls up, and play/pause
|
||||||
indication = null,
|
// and space do what they say.
|
||||||
enabled = overlays,
|
.focusRequester(keys)
|
||||||
onClick = { showControls = !showControls },
|
.onKeyEvent { event ->
|
||||||
),
|
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionLeft, Key.DirectionRight -> {
|
||||||
|
if (showControls) return@onKeyEvent false
|
||||||
|
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
|
||||||
|
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||||
|
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
|
||||||
|
if (showControls) return@onKeyEvent false
|
||||||
|
showControls = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlayPause, Key.Spacebar -> {
|
||||||
|
host.setPaused(!current.paused)
|
||||||
|
showControls = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlay -> { host.setPaused(false); true }
|
||||||
|
Key.MediaPause -> { host.setPaused(true); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
// 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(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -393,7 +536,7 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = overlays && showControls,
|
visible = showControls,
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -450,7 +593,7 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = overlays && showControls,
|
visible = showControls,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -468,7 +611,10 @@ fun PlayerScreen(
|
|||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
playing = index,
|
playing = index,
|
||||||
onEpisode = { jumpTo(it) },
|
onEpisode = { jumpTo(it) },
|
||||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
onRotate = {
|
||||||
|
rotationChosen = true
|
||||||
|
landscapeLocked = activity?.toggleLandscape() ?: false
|
||||||
|
},
|
||||||
landscapeLocked = landscapeLocked,
|
landscapeLocked = landscapeLocked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -476,7 +622,7 @@ fun PlayerScreen(
|
|||||||
// Drawn whether or not the rest of the controls are up: an opening lasts
|
// 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
|
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||||
// reaches in time.
|
// reaches in time.
|
||||||
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
|
offer?.takeIf { !autoSkip }?.let { skip ->
|
||||||
TonalButton(
|
TonalButton(
|
||||||
onClick = { nativeHost.seek(skip.target) },
|
onClick = { nativeHost.seek(skip.target) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -486,21 +632,59 @@ fun PlayerScreen(
|
|||||||
) { Text(skip.label) }
|
) { 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(
|
UpNextCard(
|
||||||
next = next,
|
next = next,
|
||||||
visible = overlays && autoplay && !state.paused &&
|
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
||||||
chapters.creditsStart?.let { state.position >= it } == true,
|
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||||
|
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||||
onPlayNow = { advance() },
|
onPlayNow = { advance() },
|
||||||
|
onStay = { stay = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
.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
|
// 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
|
// more useful thing to say, and a spinner over it reads as "still
|
||||||
// trying".
|
// trying".
|
||||||
if (state.stalled && playbackError == null) {
|
if ((opening || state.stalled) && playbackError == null) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
@@ -512,10 +696,13 @@ fun PlayerScreen(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
)
|
)
|
||||||
if (state.buffering) {
|
if (opening || state.buffering) {
|
||||||
Text(
|
Text(
|
||||||
state.cachePercent.takeIf { it > 0.0 }
|
when {
|
||||||
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
opening -> "Opening ${episode.label}…"
|
||||||
|
state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%"
|
||||||
|
else -> "Buffering…"
|
||||||
|
},
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
@@ -536,14 +723,29 @@ fun PlayerScreen(
|
|||||||
// Dismissible, because the message is not always fatal — a
|
// Dismissible, because the message is not always fatal — a
|
||||||
// subtitle track that failed to load leaves an episode that
|
// subtitle track that failed to load leaves an episode that
|
||||||
// plays perfectly well behind an error that used to be permanent.
|
// plays perfectly well behind an error that used to be permanent.
|
||||||
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
Row {
|
||||||
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
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(
|
private data class Startup(
|
||||||
val nativeHandle: ULong,
|
val nativeHandle: ULong,
|
||||||
val size: ULong,
|
val size: ULong,
|
||||||
@@ -588,6 +790,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. */
|
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
|
||||||
private fun Activity.toggleLandscape(): Boolean {
|
private fun Activity.toggleLandscape(): Boolean {
|
||||||
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
@@ -634,8 +856,15 @@ private fun PlayerControls(
|
|||||||
}
|
}
|
||||||
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val roomForVolume = with(density) {
|
val width = with(density) { LocalWindowInfo.current.containerSize.width.toDp() }
|
||||||
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
|
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(
|
Column(
|
||||||
@@ -697,45 +926,80 @@ private fun PlayerControls(
|
|||||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (episodes.size > 1) {
|
if (!roomForAll) {
|
||||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||||
}
|
var more by remember { mutableStateOf(false) }
|
||||||
if (chapters.size > 1) {
|
Box {
|
||||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
|
||||||
}
|
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
|
||||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
val entries = buildList {
|
||||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
|
||||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
|
||||||
CompactIcon(
|
add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
|
||||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
|
||||||
"Mute",
|
add(
|
||||||
) {
|
Triple(
|
||||||
host.setMuted(!state.muted)
|
if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||||
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
if (state.muted) "Unmute" else "Mute",
|
||||||
}
|
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
|
add(
|
||||||
// has the system volume keys and no width to spare.
|
Triple(
|
||||||
if (roomForVolume) {
|
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||||
Slider(
|
onRotate,
|
||||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
),
|
||||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
)
|
||||||
onValueChangeFinished = {
|
}
|
||||||
val chosen = dragging?.toDouble()
|
entries.forEach { (icon, label, action) ->
|
||||||
dragging = null
|
DropdownMenuItem(
|
||||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
text = { Text(label) },
|
||||||
},
|
leadingIcon = { Icon(icon, contentDescription = null) },
|
||||||
valueRange = 0f..MAX_VOLUME,
|
onClick = { more = false; action() },
|
||||||
modifier = Modifier.width(96.dp),
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 +1260,31 @@ private fun EpisodeChooser(
|
|||||||
*
|
*
|
||||||
* "Watch till the end" holds for the rest of the file: someone who wants the
|
* "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.
|
* 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
|
@Composable
|
||||||
private fun UpNextCard(
|
private fun UpNextCard(
|
||||||
next: EpisodeRecord?,
|
next: EpisodeRecord?,
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
secondsLeft: Int?,
|
||||||
onPlayNow: () -> Unit,
|
onPlayNow: () -> Unit,
|
||||||
|
onStay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var held by remember(next?.linkId) { mutableStateOf(false) }
|
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 upNext = next
|
||||||
val showing = visible && upNext != null && !held
|
val showing = visible && upNext != null && !held
|
||||||
LaunchedEffect(showing) {
|
LaunchedEffect(showing, secondsLeft == null) {
|
||||||
if (!showing) return@LaunchedEffect
|
if (!showing || secondsLeft != null) return@LaunchedEffect
|
||||||
remaining = UP_NEXT_SECONDS
|
counted = UP_NEXT_SECONDS
|
||||||
while (remaining > 0) {
|
while (counted > 0) {
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
remaining -= 1
|
counted -= 1
|
||||||
}
|
}
|
||||||
onPlayNow()
|
onPlayNow()
|
||||||
}
|
}
|
||||||
@@ -1076,8 +1347,49 @@ private fun UpNextCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
if (secondsLeft != null) {
|
||||||
Text("Watch till the end")
|
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 +1431,14 @@ private fun CompactIcon(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
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(
|
||||||
icon,
|
icon,
|
||||||
contentDescription = description,
|
contentDescription = description,
|
||||||
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
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
|
package io.narl.protonstream.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The settings that are Android's alone.
|
* The settings that are Android's alone.
|
||||||
@@ -35,6 +36,31 @@ class SettingsStore(context: Context) {
|
|||||||
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||||
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
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) } }
|
||||||
|
|
||||||
|
/** Whether a background crawl may say that a show gained episodes. */
|
||||||
|
var newEpisodeNotifications: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
|
||||||
|
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||||
@@ -44,10 +70,20 @@ class SettingsStore(context: Context) {
|
|||||||
|
|
||||||
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
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 NAME = "proton_stream_settings"
|
||||||
private const val KEY_WIFI_ONLY = "wifi_only"
|
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||||
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||||
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||||
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
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_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
|
||||||
|
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package io.narl.protonstream.sync
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.narl.protonstream.MainActivity
|
||||||
|
import io.narl.protonstream.R
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import uniffi.pstr_android.NewEpisodesRecord
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look at the shares while the app is away, and say when a show gained episodes.
|
||||||
|
*
|
||||||
|
* The app crawls a share it has not looked at for six hours whenever it opens,
|
||||||
|
* which finds a new episode only once someone opens it to look. This runs the
|
||||||
|
* same crawl on WorkManager's schedule instead. It only reports files the
|
||||||
|
* crawl itself found, so a share the app crawled since says nothing twice.
|
||||||
|
*/
|
||||||
|
class NewEpisodesWorker(
|
||||||
|
appContext: Context,
|
||||||
|
parameters: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
|
||||||
|
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
|
||||||
|
if (news.isNotEmpty()) post(news)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(news: List<NewEpisodesRecord>) {
|
||||||
|
if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = "A show in your library gained episodes"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
Intent(applicationContext, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val compat = NotificationManagerCompat.from(applicationContext)
|
||||||
|
// One per show, keyed by it, so a second crawl's news for the same show
|
||||||
|
// replaces the first rather than stacking under it.
|
||||||
|
news.forEach { item ->
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
|
.setContentTitle(item.title)
|
||||||
|
.setContentText(describe(item))
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
if (news.size > 1) {
|
||||||
|
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
|
.setContentTitle("New episodes")
|
||||||
|
.setContentText(news.joinToString { it.title })
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "new-episodes"
|
||||||
|
private const val CHANNEL_ID = "new-episodes"
|
||||||
|
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
|
||||||
|
private const val SUMMARY_TAG = "new-episodes-summary"
|
||||||
|
private const val NOTIFICATION_ID = 7
|
||||||
|
private const val INTERVAL_HOURS = 6L
|
||||||
|
|
||||||
|
/** What one show's notification says: "S01E04 and 2 more". */
|
||||||
|
fun describe(news: NewEpisodesRecord): String {
|
||||||
|
val more = news.count.toInt() - 1
|
||||||
|
val first = news.first
|
||||||
|
return when {
|
||||||
|
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
|
||||||
|
more == 0 -> "$first is new"
|
||||||
|
more == 1 -> "$first and 1 more are new"
|
||||||
|
else -> "$first and $more more are new"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the periodic look scheduled, or cancel it when the viewer
|
||||||
|
* turned it off. Called on every launch; keeping an existing schedule
|
||||||
|
* means a launch does not push the next look back.
|
||||||
|
*/
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val work = WorkManager.getInstance(context.applicationContext)
|
||||||
|
if (!SettingsStore(context).newEpisodeNotifications) {
|
||||||
|
work.cancelUniqueWork(WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,8 +143,9 @@ internal fun AccountCard(
|
|||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
"Optional. Signed in, watch history is kept in your own Drive so every device " +
|
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||||
"resumes where another left off, and folders of your Drive can join the library.",
|
"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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -156,8 +157,8 @@ internal fun AccountCard(
|
|||||||
|
|
||||||
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||||
internal fun syncLine(account: AccountUiState, now: Long): String {
|
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||||
account.syncError?.let { return "Watch history did not sync: $it" }
|
account.syncError?.let { return "Did not sync: $it" }
|
||||||
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
|
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||||
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||||
val ago = when {
|
val ago = when {
|
||||||
minutes < 1 -> "just now"
|
minutes < 1 -> "just now"
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ import kotlinx.coroutines.flow.callbackFlow
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
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.ArrangementRecord
|
||||||
|
import uniffi.pstr_android.LibraryFilter
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
@@ -38,12 +41,15 @@ import uniffi.pstr_android.EpisodeRecord
|
|||||||
import uniffi.pstr_android.WatchStateRecord
|
import uniffi.pstr_android.WatchStateRecord
|
||||||
import uniffi.pstr_android.AccountState
|
import uniffi.pstr_android.AccountState
|
||||||
import uniffi.pstr_android.SignInOutcome
|
import uniffi.pstr_android.SignInOutcome
|
||||||
|
import uniffi.pstr_android.SyncRecord
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
|
/** A crawl is running, which the banner offers to stop. */
|
||||||
|
val crawling: Boolean = false,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val titles: List<TitleRecord> = emptyList(),
|
val titles: List<TitleRecord> = emptyList(),
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +58,7 @@ data class AppUiState(
|
|||||||
* when the grid falls back to [titles] as they are.
|
* when the grid falls back to [titles] as they are.
|
||||||
*/
|
*/
|
||||||
val arrangement: ArrangementRecord? = null,
|
val arrangement: ArrangementRecord? = null,
|
||||||
|
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||||
val sort: LibrarySort = LibrarySort.NAME,
|
val sort: LibrarySort = LibrarySort.NAME,
|
||||||
val grouped: Boolean = true,
|
val grouped: Boolean = true,
|
||||||
val shares: List<ShareRecord> = emptyList(),
|
val shares: List<ShareRecord> = emptyList(),
|
||||||
@@ -64,6 +71,12 @@ data class AppUiState(
|
|||||||
),
|
),
|
||||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||||
val message: String? = null,
|
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. */
|
/** What Undo on [message] puts back, when the message offers one. */
|
||||||
val undo: List<WatchSnapshot>? = null,
|
val undo: List<WatchSnapshot>? = null,
|
||||||
val account: AccountUiState = AccountUiState(),
|
val account: AccountUiState = AccountUiState(),
|
||||||
@@ -99,6 +112,21 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
private val mutableState = MutableStateFlow(AppUiState())
|
private val mutableState = MutableStateFlow(AppUiState())
|
||||||
private val searchQuery = MutableStateFlow("")
|
private val searchQuery = MutableStateFlow("")
|
||||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
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 {
|
init {
|
||||||
if (!NativeRuntime.tlsReady) {
|
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")
|
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 {
|
viewModelScope.launch {
|
||||||
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||||
.getOrDefault(AccountState.SignedOut)
|
.getOrDefault(AccountState.SignedOut)
|
||||||
@@ -116,6 +159,9 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
// and no upload.
|
// and no upload.
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
syncWatchHistory()
|
syncWatchHistory()
|
||||||
|
// A sync WatchSyncWorker ran while the app was away may have
|
||||||
|
// brought shares this one has not crawled.
|
||||||
|
catchUp()
|
||||||
delay(SYNC_INTERVAL_MS)
|
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) {
|
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||||
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
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) {
|
fun search(query: String) {
|
||||||
@@ -144,14 +207,72 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
busy("Refreshing the library…") {
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
mutableState.update { it.copy(message = null) }
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
runCatching { crawlShares(null) }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
reload()
|
}
|
||||||
|
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.
|
* 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) {
|
fun refreshShare(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
busy("Reading $name…") {
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
mutableState.update { it.copy(message = null) }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
runCatching { crawlShares(id) }
|
||||||
reload()
|
.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.
|
* changed.
|
||||||
*/
|
*/
|
||||||
fun matchTitles(force: Boolean) {
|
fun matchTitles(force: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { match(force) }
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) } }
|
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) } }
|
.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 {
|
viewModelScope.launch {
|
||||||
runCatching {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||||
val engine = NativeRuntime.engine()
|
.getOrDefault(emptyList())
|
||||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
crawlAdded(ids, announce = false)
|
||||||
engine.crawl(null)
|
} finally {
|
||||||
}
|
catchingUp = false
|
||||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
}
|
||||||
reload()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +418,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
engine.crawl(id)
|
engine.crawl(id)
|
||||||
}
|
}
|
||||||
}.onFailure(::reportError)
|
}.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()
|
reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +562,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
syncError = null,
|
syncError = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (report.applied > 0u) reload()
|
takeSynced(report)
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
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. */
|
/** After the player closes: where it stopped is what another device wants next. */
|
||||||
fun playerClosed() {
|
fun playerClosed() {
|
||||||
reload()
|
reload()
|
||||||
@@ -376,16 +611,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true) }
|
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||||
val engine = NativeRuntime.engine()
|
}.onSuccess { share ->
|
||||||
val share = engine.addAccountFolder(name, volumeId, linkId)
|
reload()
|
||||||
engine.crawl(share.id)
|
crawlAdded(listOf(share.id), announce = true)
|
||||||
}
|
|
||||||
}.onFailure(::reportError)
|
}.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
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
* menu should not cost them.
|
* menu should not cost them.
|
||||||
*/
|
*/
|
||||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||||
val episodes = title.seasons.flatMap { it.episodes }
|
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(
|
changeWatch(
|
||||||
episodes,
|
episodes.filter { it.watched != watched },
|
||||||
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
|
"$what marked ${if (watched) "watched" else "unwatched"}",
|
||||||
) { engine, episode, before ->
|
) { engine, episode, before ->
|
||||||
val duration = before?.durationSecs
|
val duration = before?.durationSecs
|
||||||
engine.saveWatchState(
|
engine.saveWatchState(
|
||||||
@@ -446,14 +683,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
snapshots.forEach { snapshot ->
|
snapshots.forEach { snapshot ->
|
||||||
val state = snapshot.state
|
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||||
engine.saveWatchState(
|
|
||||||
snapshot.shareId,
|
|
||||||
snapshot.linkId,
|
|
||||||
state?.positionSecs ?: 0.0,
|
|
||||||
state?.durationSecs,
|
|
||||||
state?.watched ?: false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure(::reportError)
|
}.onFailure(::reportError)
|
||||||
@@ -487,22 +717,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
/**
|
||||||
viewModelScope.launch {
|
* Store the enrichment settings, and return why not, if they were not.
|
||||||
runCatching {
|
*
|
||||||
withContext(Dispatchers.IO) {
|
* The page shows the new settings as soon as they are stored; the match
|
||||||
val engine = NativeRuntime.engine()
|
* run that follows can take minutes and says so in the activity banner.
|
||||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
*/
|
||||||
engine.setMetadataApiKey(provider, apiKey)
|
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||||
}
|
val stored = runCatching {
|
||||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
withContext(Dispatchers.IO) {
|
||||||
if (enabled) engine.matchTitles(false) else null
|
val engine = NativeRuntime.engine()
|
||||||
|
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||||
|
engine.setMetadataApiKey(provider, apiKey)
|
||||||
}
|
}
|
||||||
}.onSuccess { summary ->
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
engine.metadataSettings()
|
||||||
}.onFailure(::reportError)
|
}
|
||||||
reload()
|
}.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()
|
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
|
* at its duration where one is known, so the progress bar agrees with the
|
||||||
* tick.
|
* tick.
|
||||||
*/
|
*/
|
||||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
/** One episode's tick, with the same Undo a season's has. */
|
||||||
viewModelScope.launch {
|
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val engine = NativeRuntime.engine()
|
|
||||||
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
|
||||||
engine.saveWatchState(
|
|
||||||
episode.shareId,
|
|
||||||
episode.linkId,
|
|
||||||
if (watched) duration ?: 0.0 else 0.0,
|
|
||||||
duration,
|
|
||||||
watched,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure(::reportError)
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAllOffline() {
|
fun removeAllOffline() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -629,40 +848,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { reloadNow() }
|
||||||
runCatching {
|
}
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val engine = NativeRuntime.engine()
|
/** [reload], for a caller that goes on to read what it loaded. */
|
||||||
// Here rather than inside `library()`, which is read-only
|
private suspend fun reloadNow() {
|
||||||
// and cached: a full reload is the one place that has
|
runCatching {
|
||||||
// already paid for a walk of the offline files.
|
withContext(Dispatchers.IO) {
|
||||||
engine.pruneOfflineFiles()
|
val engine = NativeRuntime.engine()
|
||||||
val current = mutableState.value
|
// Here rather than inside `library()`, which is read-only
|
||||||
val query = current.query.takeIf(String::isNotBlank)
|
// and cached: a full reload is the one place that has
|
||||||
Reloaded(
|
// already paid for a walk of the offline files.
|
||||||
engine.shares(),
|
engine.pruneOfflineFiles()
|
||||||
engine.library(query),
|
val current = mutableState.value
|
||||||
engine.arrangement(query, current.sort, current.grouped),
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
engine.offlineFiles(),
|
Reloaded(
|
||||||
engine.metadataSettings(),
|
engine.shares(),
|
||||||
engine.storageUsage(),
|
engine.library(query),
|
||||||
)
|
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||||
}
|
engine.offlineFiles(),
|
||||||
}.onSuccess { result ->
|
engine.metadataSettings(),
|
||||||
mutableState.update { it.copy(
|
engine.storageUsage(),
|
||||||
loading = false,
|
)
|
||||||
shares = result.shares,
|
}
|
||||||
titles = result.titles,
|
}.onSuccess { result ->
|
||||||
arrangement = result.arrangement,
|
mutableState.update { it.copy(
|
||||||
offline = result.offline,
|
loading = false,
|
||||||
metadataSettings = result.metadataSettings,
|
shares = result.shares,
|
||||||
storage = result.storage,
|
titles = result.titles,
|
||||||
) }
|
arrangement = result.arrangement,
|
||||||
}
|
offline = result.offline,
|
||||||
.onFailure { error ->
|
metadataSettings = result.metadataSettings,
|
||||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
storage = result.storage,
|
||||||
}
|
) }
|
||||||
}
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadLibrary(query: String) {
|
private suspend fun reloadLibrary(query: String) {
|
||||||
@@ -671,7 +893,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
val search = query.takeIf(String::isNotBlank)
|
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) ->
|
}.onSuccess { (titles, arrangement) ->
|
||||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -15,11 +18,22 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarColors
|
import androidx.compose.material3.TopAppBarColors
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
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.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
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.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
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,21 +107,33 @@ internal fun Modifier.sharedArt(key: String): Modifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
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,
|
* Where Play on a title lands: what Continue watching offers
|
||||||
* else the first unwatched one, else the first — the desktop's `next_up`.
|
* ([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 =
|
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||||
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
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 }
|
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||||
?: 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
|
* Download episodes, asking first when it is more than one: a season or a
|
||||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
* 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(
|
@Composable
|
||||||
download.status.replaceFirstChar { it.uppercase() },
|
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 {
|
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||||
},
|
},
|
||||||
@@ -135,6 +202,25 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
|
|||||||
?.let { "${formatBytes(it.toULong())}/s" },
|
?.let { "${formatBytes(it.toULong())}/s" },
|
||||||
).joinToString(" · ")
|
).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".
|
* 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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -40,7 +42,7 @@ import androidx.work.WorkManager
|
|||||||
import io.narl.protonstream.download.DownloadCoordinator
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
import io.narl.protonstream.download.DownloadStateStore
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
import io.narl.protonstream.download.RetainedDownload
|
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.AccentProgress
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
import uniffi.pstr_android.OfflineRecord
|
import uniffi.pstr_android.OfflineRecord
|
||||||
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
|
|||||||
onResume: (RetainedDownload) -> Unit,
|
onResume: (RetainedDownload) -> Unit,
|
||||||
onDeletePartial: (RetainedDownload) -> Unit,
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||||
|
onOpen: (TitleRecord) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||||
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
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 = {
|
confirmButton = {
|
||||||
AccentButton(onClick = {
|
DangerButton(onClick = {
|
||||||
deleting = null
|
deleting = null
|
||||||
onRemove(file)
|
onRemove(file)
|
||||||
}) { Text("Delete") }
|
}) { 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. */
|
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||||
@Composable
|
@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
|
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(
|
ListItem(
|
||||||
|
modifier = if (title != null && index >= 0) {
|
||||||
|
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
episode?.stillUrl ?: title?.backdropUrl,
|
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(" · "))
|
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = onDelete) {
|
Row {
|
||||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
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),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
@@ -204,7 +229,7 @@ private fun PartialRow(
|
|||||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Column {
|
Column {
|
||||||
Text(downloadStatusLine(download))
|
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||||
if (download.total > 0L) {
|
if (download.total > 0L) {
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
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 androidx.compose.ui.graphics.Brush
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
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 java.time.LocalDate
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
@@ -70,6 +73,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.LibraryFilter
|
||||||
import uniffi.pstr_android.LibrarySort
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
@@ -96,11 +103,50 @@ import uniffi.pstr_android.TitleType
|
|||||||
private const val CONTINUE_WATCHING_MAX = 12
|
private const val CONTINUE_WATCHING_MAX = 12
|
||||||
|
|
||||||
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
/** 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. */
|
/** 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)
|
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. */
|
/** 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>)
|
private data class Shelf(val label: String, val titles: List<TitleRecord>)
|
||||||
|
|
||||||
@@ -118,22 +164,31 @@ internal fun LibraryScreen(
|
|||||||
onMatchChanged: () -> Unit = {},
|
onMatchChanged: () -> Unit = {},
|
||||||
onError: (Throwable) -> Unit = {},
|
onError: (Throwable) -> Unit = {},
|
||||||
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onAddShare: () -> Unit = {},
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val download = rememberDownload()
|
||||||
val context = LocalContext.current
|
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.
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||||
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
// Most recently played first, one episode per show: a shelf that lists four
|
// 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.
|
||||||
|
// 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) {
|
val resumable = remember(state.titles) {
|
||||||
state.titles.mapNotNull { title ->
|
state.titles.mapNotNull { title ->
|
||||||
|
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
|
||||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
playlist.withIndex()
|
playlist.getOrNull(index)?.let { episode ->
|
||||||
.filter { it.value.resumeAt != null && !it.value.watched }
|
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
|
||||||
.maxByOrNull { it.value.lastPlayed }
|
}
|
||||||
?.let { Resumable(title, it.value, it.index) }
|
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
|
||||||
}
|
}
|
||||||
// Search is an icon until it is wanted: a permanent field above the
|
// 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.
|
// library spent a band of every visit on something used in few of them.
|
||||||
@@ -179,9 +234,16 @@ internal fun LibraryScreen(
|
|||||||
"Nothing matches",
|
"Nothing matches",
|
||||||
"No title in the library is called “${state.query.trim()}”.",
|
"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(
|
state.titles.isEmpty() -> EmptyState(
|
||||||
"Your library is empty",
|
"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(
|
else -> LibraryGrid(
|
||||||
state,
|
state,
|
||||||
@@ -190,6 +252,15 @@ internal fun LibraryScreen(
|
|||||||
onTitle,
|
onTitle,
|
||||||
onTitleMenu = { menuFor = it },
|
onTitleMenu = { menuFor = it },
|
||||||
onResumeMenu = { menuResume = 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
|
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||||
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||||
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
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.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(
|
MenuRow(
|
||||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||||
@@ -384,6 +455,8 @@ private fun LibraryGrid(
|
|||||||
onTitle: (TitleRecord) -> Unit,
|
onTitle: (TitleRecord) -> Unit,
|
||||||
onTitleMenu: (TitleRecord) -> Unit,
|
onTitleMenu: (TitleRecord) -> Unit,
|
||||||
onResumeMenu: (Resumable) -> Unit,
|
onResumeMenu: (Resumable) -> Unit,
|
||||||
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
|
metadataOffer: MetadataOffer? = null,
|
||||||
) {
|
) {
|
||||||
// The arrangement names titles by key; before it has arrived, the titles
|
// The arrangement names titles by key; before it has arrived, the titles
|
||||||
// as they came are the grid.
|
// 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
|
// 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
|
// through. Inside the grid rather than pinned over it, so the shelf
|
||||||
// scrolls away with the rest.
|
// 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
|
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||||
featured?.let { title ->
|
featured?.let { title ->
|
||||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
@@ -441,7 +517,10 @@ private fun LibraryGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.query.isBlank()) {
|
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) }) {
|
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Column {
|
Column {
|
||||||
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
|
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) }) {
|
||||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
Column {
|
||||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
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)) {
|
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)
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(if (resume != null) "Resume" else "Play")
|
Text(if (resume != null) "Resume" else "Play")
|
||||||
@@ -585,14 +684,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
|||||||
Modifier
|
Modifier
|
||||||
.width(220.dp)
|
.width(220.dp)
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.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)) {
|
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||||
|
// Named by the text under it; read as well, the name came twice.
|
||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
entry.title.displayName,
|
entry.title.displayName,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize().clearAndSetSemantics {},
|
||||||
fallback = entry.episode.thumbnailSource,
|
fallback = entry.episode.thumbnailSource,
|
||||||
|
maxEdge = 220.dp,
|
||||||
)
|
)
|
||||||
entry.episode.progress?.let { progress ->
|
entry.episode.progress?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -609,7 +710,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
|||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -638,7 +743,7 @@ private fun PosterTile(
|
|||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
|
||||||
) {
|
) {
|
||||||
Box {
|
Box {
|
||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
@@ -647,8 +752,11 @@ private fun PosterTile(
|
|||||||
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(2f / 3f)
|
.aspectRatio(2f / 3f)
|
||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clearAndSetSemantics {},
|
||||||
fallback = title.thumbnailSource,
|
fallback = title.thumbnailSource,
|
||||||
|
// The tile's height, at the widest a grid column gets.
|
||||||
|
maxEdge = 280.dp,
|
||||||
)
|
)
|
||||||
if (folded > 0) {
|
if (folded > 0) {
|
||||||
Text(
|
Text(
|
||||||
@@ -660,7 +768,8 @@ private fun PosterTile(
|
|||||||
.padding(6.dp)
|
.padding(6.dp)
|
||||||
.clip(MaterialTheme.shapes.small)
|
.clip(MaterialTheme.shapes.small)
|
||||||
.background(Color.Black.copy(alpha = 0.7f))
|
.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
|
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.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
@@ -65,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
@@ -98,7 +110,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ProtonStreamApp(
|
fun ProtonStreamApp(
|
||||||
playerHost: LibmpvHost? = null,
|
playerHost: LibmpvHost? = null,
|
||||||
inPictureInPicture: Boolean = false,
|
|
||||||
shareLink: String? = null,
|
shareLink: String? = null,
|
||||||
onShareLinkTaken: () -> Unit = {},
|
onShareLinkTaken: () -> Unit = {},
|
||||||
model: AppViewModel = viewModel(
|
model: AppViewModel = viewModel(
|
||||||
@@ -123,6 +134,7 @@ fun ProtonStreamApp(
|
|||||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||||
// into the video they had just left.
|
// into the video they had just left.
|
||||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var addingShare by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||||
val playing = playingTitleKey
|
val playing = playingTitleKey
|
||||||
@@ -216,6 +228,12 @@ fun ProtonStreamApp(
|
|||||||
onMatchChanged = model::reloadAfterMetadataChange,
|
onMatchChanged = model::reloadAfterMetadataChange,
|
||||||
onError = model::reportError,
|
onError = model::reportError,
|
||||||
onArrange = model::arrange,
|
onArrange = model::arrange,
|
||||||
|
onAddShare = {
|
||||||
|
destination = Destination.Shares
|
||||||
|
addingShare = true
|
||||||
|
},
|
||||||
|
onOpenSettings = { destination = Destination.Settings },
|
||||||
|
onFilter = model::filter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val titlePane = @Composable { selected: TitleRecord ->
|
val titlePane = @Composable { selected: TitleRecord ->
|
||||||
@@ -232,6 +250,7 @@ fun ProtonStreamApp(
|
|||||||
model::reloadAfterMetadataChange,
|
model::reloadAfterMetadataChange,
|
||||||
model::setWatched,
|
model::setWatched,
|
||||||
body,
|
body,
|
||||||
|
onMarkEpisodes = model::markEpisodes,
|
||||||
franchise = selected.franchise.mapNotNull { key ->
|
franchise = selected.franchise.mapNotNull { key ->
|
||||||
state.titles.firstOrNull { it.key == key }
|
state.titles.firstOrNull { it.key == key }
|
||||||
},
|
},
|
||||||
@@ -263,12 +282,21 @@ fun ProtonStreamApp(
|
|||||||
}
|
}
|
||||||
Destination.Shares -> SharesScreen(
|
Destination.Shares -> SharesScreen(
|
||||||
state.shares,
|
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::repairShare,
|
||||||
model::refreshShare,
|
model::refreshShare,
|
||||||
model::removeShare,
|
model::removeShare,
|
||||||
shareLink,
|
// The empty library's button opens the form as a
|
||||||
onShareLinkTaken,
|
// pasted link would, only with nothing in it.
|
||||||
|
shareLink ?: "".takeIf { addingShare },
|
||||||
|
{ addingShare = false; onShareLinkTaken() },
|
||||||
body,
|
body,
|
||||||
account = state.account,
|
account = state.account,
|
||||||
accountActions = AccountActions(
|
accountActions = AccountActions(
|
||||||
@@ -282,6 +310,7 @@ fun ProtonStreamApp(
|
|||||||
addFolder = model::addAccountFolder,
|
addFolder = model::addAccountFolder,
|
||||||
onError = model::reportError,
|
onError = model::reportError,
|
||||||
),
|
),
|
||||||
|
onRename = model::renameShare,
|
||||||
)
|
)
|
||||||
Destination.History -> HistoryScreen(
|
Destination.History -> HistoryScreen(
|
||||||
state,
|
state,
|
||||||
@@ -301,6 +330,15 @@ fun ProtonStreamApp(
|
|||||||
model::resumeDownload,
|
model::resumeDownload,
|
||||||
model::deletePartial,
|
model::deletePartial,
|
||||||
body,
|
body,
|
||||||
|
onPlay = { title, index ->
|
||||||
|
playingTitleKey = title.key
|
||||||
|
playingIndex = index
|
||||||
|
playerMinimized = false
|
||||||
|
},
|
||||||
|
onOpen = { title ->
|
||||||
|
destination = Destination.Library
|
||||||
|
selectedTitleKey = title.key
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Destination.Settings -> SettingsScreen(
|
Destination.Settings -> SettingsScreen(
|
||||||
state,
|
state,
|
||||||
@@ -327,22 +365,28 @@ fun ProtonStreamApp(
|
|||||||
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
.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
|
// 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
|
// 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
|
// is what lets it draw into the cutout — but the page behind it stays
|
||||||
// Picture-in-Picture — but the page behind it stays composed, so leaving the
|
// composed, so leaving the player finds the title page where it was rather
|
||||||
// player finds the title page where it was rather than rebuilt from the top.
|
// than rebuilt from the top.
|
||||||
playing?.let { request ->
|
playing?.let { request ->
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
episodes = request.episodes,
|
episodes = request.episodes,
|
||||||
index = request.index,
|
index = request.index,
|
||||||
host = playerHost,
|
host = playerHost,
|
||||||
inPictureInPicture = inPictureInPicture,
|
|
||||||
minimized = playerMinimized,
|
minimized = playerMinimized,
|
||||||
onIndexChange = { playingIndex = it },
|
onIndexChange = { playingIndex = it },
|
||||||
onSaveProgress = model::saveProgress,
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -87,16 +88,20 @@ internal fun RemoteArtwork(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
labelled: Boolean = true,
|
labelled: Boolean = true,
|
||||||
|
maxEdge: Dp? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val enabled = LocalArtworkLoading.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) }
|
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||||
LaunchedEffect(url, fallback, enabled) {
|
LaunchedEffect(url, fallback, enabled) {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
image = ArtworkLoad.Missing
|
image = ArtworkLoad.Missing
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
|
||||||
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||||
?: ArtworkLoad.Missing
|
?: 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
|
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||||
* shows costs no second download.
|
* shows costs no second download.
|
||||||
*/
|
*/
|
||||||
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||||
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val uri = URI(requested)
|
val uri = URI(requested)
|
||||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
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 cached = directory.resolve("$digest.img")
|
||||||
val fresh = !cached.isFile
|
val fresh = !cached.isFile
|
||||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
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) {
|
if (fresh) {
|
||||||
// Written only once it has decoded. Caching the bytes first is
|
// Written only once it has decoded. Caching the bytes first is
|
||||||
// what turns one hostile poster into a crash on every launch
|
// 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())
|
cached.setLastModified(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
bitmap
|
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
|
* share or a session that has to be reopened is transient, and the next
|
||||||
* composition should try again.
|
* 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 file = source ?: return null
|
||||||
val key = file.key
|
val key = file.key
|
||||||
decoded.get(key)?.let { return it }
|
decoded.get(sized(key, edge))?.let { return it }
|
||||||
if (key in missing) return null
|
if (key in missing) return null
|
||||||
val fetched = withContext(Dispatchers.IO) {
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||||
}
|
}
|
||||||
val bytes = fetched.getOrElse { return null }
|
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) {
|
if (bitmap == null) {
|
||||||
missing.add(key)
|
missing.add(key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
decoded.put(key, bitmap)
|
decoded.put(sized(key, edge), bitmap)
|
||||||
return 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
|
* [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
|
* 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
|
* `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.
|
* 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 }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||||
if (longest <= 0) return null
|
if (longest <= 0) return null
|
||||||
var sample = 1
|
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(
|
return BitmapFactory.decodeByteArray(
|
||||||
bytes,
|
bytes,
|
||||||
0,
|
0,
|
||||||
bytes.size,
|
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.
|
* 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.
|
* is a full-resolution decode per item.
|
||||||
*/
|
*/
|
||||||
private val decoded = object : LruCache<String, Bitmap>(
|
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
|
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,12 +59,14 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.compose.ui.window.SecureFlagPolicy
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import io.narl.protonstream.download.DownloadCoordinator
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
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.AppearanceState
|
||||||
import io.narl.protonstream.ui.theme.EdgedButton
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
@@ -86,7 +88,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsScreen(
|
internal fun SettingsScreen(
|
||||||
state: AppUiState,
|
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,
|
onMatchAgain: () -> Unit,
|
||||||
onClearCache: () -> Unit,
|
onClearCache: () -> Unit,
|
||||||
onRemoveAllOffline: () -> Unit,
|
onRemoveAllOffline: () -> Unit,
|
||||||
@@ -102,6 +105,7 @@ internal fun SettingsScreen(
|
|||||||
val settings = remember { SettingsStore(context) }
|
val settings = remember { SettingsStore(context) }
|
||||||
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
|
||||||
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||||
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||||
// Playback preferences are the shared store's, not this app's: they are the
|
// Playback preferences are the shared store's, not this app's: they are the
|
||||||
@@ -205,6 +209,15 @@ internal fun SettingsScreen(
|
|||||||
backgroundAudio = it
|
backgroundAudio = it
|
||||||
settings.backgroundAudio = it
|
settings.backgroundAudio = it
|
||||||
}
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"New episode notifications",
|
||||||
|
"Looks at your shares every few hours and says when a show gains episodes",
|
||||||
|
newEpisodes,
|
||||||
|
) {
|
||||||
|
newEpisodes = it
|
||||||
|
settings.newEpisodeNotifications = it
|
||||||
|
NewEpisodesWorker.schedule(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||||
@@ -315,7 +328,7 @@ internal fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
@@ -327,10 +340,7 @@ internal fun SettingsScreen(
|
|||||||
MetadataSettingsDialog(
|
MetadataSettingsDialog(
|
||||||
current = state.metadataSettings,
|
current = state.metadataSettings,
|
||||||
onDismiss = { showMetadata = false },
|
onDismiss = { showMetadata = false },
|
||||||
onSave = { enabled, provider, language, key ->
|
onSave = onSaveMetadata,
|
||||||
onSaveMetadata(enabled, provider, language, key)
|
|
||||||
showMetadata = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,14 +421,19 @@ private fun SettingAction(
|
|||||||
private fun MetadataSettingsDialog(
|
private fun MetadataSettingsDialog(
|
||||||
current: uniffi.pstr_android.MetadataSettingsRecord,
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||||
) {
|
) {
|
||||||
var enabled by remember { mutableStateOf(current.enabled) }
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
var provider by remember { mutableStateOf(current.provider) }
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
var language by remember { mutableStateOf(current.language) }
|
var language by remember { mutableStateOf(current.language) }
|
||||||
var apiKey by remember { mutableStateOf("") }
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!saving) onDismiss() },
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
title = { Text("Metadata enrichment") },
|
title = { Text("Metadata enrichment") },
|
||||||
text = {
|
text = {
|
||||||
@@ -447,15 +462,35 @@ private fun MetadataSettingsDialog(
|
|||||||
singleLine = true,
|
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 = {
|
confirmButton = {
|
||||||
AccentButton(
|
AccentButton(
|
||||||
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
enabled = !needsKey && !saving,
|
||||||
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
onClick = {
|
||||||
) { Text("Save") }
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -25,13 +30,17 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.window.SecureFlagPolicy
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.DangerButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import uniffi.pstr_android.AccountState
|
import uniffi.pstr_android.AccountState
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Key
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.FolderShared
|
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
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SharesScreen(
|
internal fun SharesScreen(
|
||||||
shares: List<ShareRecord>,
|
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,
|
onRepair: (String, String, String?) -> Unit,
|
||||||
onRefresh: (String) -> Unit,
|
onRefresh: (String) -> Unit,
|
||||||
onRemove: (String) -> Unit,
|
onRemove: (String) -> Unit,
|
||||||
@@ -59,8 +71,10 @@ internal fun SharesScreen(
|
|||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
account: AccountUiState = AccountUiState(),
|
account: AccountUiState = AccountUiState(),
|
||||||
accountActions: AccountActions? = null,
|
accountActions: AccountActions? = null,
|
||||||
|
onRename: (String, String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
var signingIn by remember { mutableStateOf(false) }
|
var signingIn by remember { mutableStateOf(false) }
|
||||||
var browsing by remember { mutableStateOf(false) }
|
var browsing by remember { mutableStateOf(false) }
|
||||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
@@ -97,6 +111,7 @@ internal fun SharesScreen(
|
|||||||
onRefresh = { onRefresh(share.id) },
|
onRefresh = { onRefresh(share.id) },
|
||||||
onRepair = { repairing = share },
|
onRepair = { repairing = share },
|
||||||
onRemove = { removing = share },
|
onRemove = { removing = share },
|
||||||
|
onRename = { renaming = share },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +150,21 @@ internal fun SharesScreen(
|
|||||||
onRepair = { url, password -> onRepair(share.id, url, password) },
|
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 ->
|
removing?.let { share ->
|
||||||
// Asked, because it cannot be undone: the link's secret and its
|
// Asked, because it cannot be undone: the link's secret and its
|
||||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||||
@@ -148,7 +178,7 @@ internal fun SharesScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
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") } },
|
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
|
* Three differently styled controls beside the name squeezed it into a column
|
||||||
* a word wide; one icon leaves the row its width.
|
* 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
|
@Composable
|
||||||
private fun ShareRow(
|
private fun ShareRow(
|
||||||
share: ShareRecord,
|
share: ShareRecord,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onRepair: () -> Unit,
|
onRepair: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
|
onRename: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Column {
|
||||||
when {
|
Text(
|
||||||
share.fromAccount -> "Folder of your Drive"
|
when {
|
||||||
share.hasCustomPassword -> "Link and custom password, stored securely"
|
share.fromAccount -> "Folder of your Drive"
|
||||||
else -> "Public link"
|
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 = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -200,6 +264,11 @@ private fun ShareRow(
|
|||||||
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
onClick = { menu = false; onRefresh() },
|
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
|
// Not a remove: a share whose secret has become unreadable
|
||||||
// cannot be removed either, since removal deletes a secret
|
// cannot be removed either, since removal deletes a secret
|
||||||
// the store can no longer touch.
|
// 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
|
@Composable
|
||||||
private fun AddShareDialog(
|
private fun AddShareDialog(
|
||||||
initialUrl: String,
|
initialUrl: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (String, String, String?) -> Unit,
|
onAdd: suspend (String, String, String?) -> String?,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
|
var showLink by remember { mutableStateOf(false) }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!adding) onDismiss() },
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
title = { Text("Add Proton Drive share") },
|
title = { Text("Add Proton Drive share") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
url,
|
url,
|
||||||
{ url = it },
|
{ url = it },
|
||||||
label = { Text("Public share URL") },
|
label = { Text("Share link") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
// The URL fragment is a secret. Password input disables
|
// The URL fragment is a secret. Password input disables
|
||||||
// IME learning/suggestions while normal long-press paste
|
// IME learning/suggestions while normal long-press paste
|
||||||
// remains available.
|
// remains available; the eye shows what was pasted.
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
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(
|
OutlinedTextField(
|
||||||
password,
|
password,
|
||||||
@@ -312,14 +404,30 @@ private fun AddShareDialog(
|
|||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
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 = {
|
confirmButton = {
|
||||||
AccentButton(
|
AccentButton(
|
||||||
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
onClick = {
|
||||||
enabled = name.isNotBlank() && url.isNotBlank(),
|
adding = true
|
||||||
) { Text("Add") }
|
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 androidx.compose.ui.graphics.graphicsLayer
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.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.Download
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.download.RetainedDownload
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
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.AccentProgress
|
||||||
import io.narl.protonstream.ui.theme.EdgedButton
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
import io.narl.protonstream.ui.theme.QuietButton
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
@@ -97,11 +104,14 @@ internal fun TitleScreen(
|
|||||||
onMetadataChanged: () -> Unit,
|
onMetadataChanged: () -> Unit,
|
||||||
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||||
padding: PaddingValues,
|
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. */
|
/** Every title of its franchise, in release order, this one included. */
|
||||||
franchise: List<TitleRecord> = emptyList(),
|
franchise: List<TitleRecord> = emptyList(),
|
||||||
onOpenTitle: (TitleRecord) -> Unit = {},
|
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val download = rememberDownload()
|
||||||
var showMatch by remember(title.key) { mutableStateOf(false) }
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
var overviewOpen 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
|
// Display order across seasons: what previous/next and autoplay walk, and
|
||||||
// the same order the desktop client uses.
|
// the same order the desktop client uses.
|
||||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
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)
|
val upNext = playlist.getOrNull(nextUp)
|
||||||
// One season on screen at a time, opened on the one Play would land in: a
|
// 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
|
// 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.
|
// without a trip to the Downloads tab.
|
||||||
val work by WorkManager.getInstance(context)
|
val work by WorkManager.getInstance(context)
|
||||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
.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
|
// 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
|
// 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)) {
|
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 }
|
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||||
// The provider's own page for this title: where a viewer goes
|
// The provider's own page for this title: where a viewer goes
|
||||||
@@ -343,7 +357,9 @@ internal fun TitleScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
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(
|
FilterChip(
|
||||||
selected = index == seasonIndex,
|
selected = index == seasonIndex,
|
||||||
onClick = { seasonIndex = index },
|
onClick = { seasonIndex = index },
|
||||||
@@ -352,7 +368,16 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
season?.let {
|
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}")
|
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,6 +399,14 @@ internal fun TitleScreen(
|
|||||||
onPause = { DownloadCoordinator.pause(context, it) },
|
onPause = { DownloadCoordinator.pause(context, it) },
|
||||||
onResume = { DownloadCoordinator.resume(context, it) },
|
onResume = { DownloadCoordinator.resume(context, it) },
|
||||||
onSetWatched = { onSetWatched(episode, 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,
|
onPause: (RetainedDownload) -> Unit,
|
||||||
onResume: (RetainedDownload) -> Unit,
|
onResume: (RetainedDownload) -> Unit,
|
||||||
onSetWatched: (Boolean) -> 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 ||
|
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
download?.status == RetainedDownload.STATUS_QUEUED
|
download?.status == RetainedDownload.STATUS_QUEUED
|
||||||
val heading = episode.heading(position)
|
val heading = episode.heading(position)
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.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),
|
.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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// With metadata off the provider has no still, and Proton's own
|
// 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
|
// 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),
|
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
downloadStatusLine(active),
|
downloadStatusLine(active, rememberQueueReason(active)),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -656,7 +708,33 @@ internal fun ChangeMatchDialog(
|
|||||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||||
var choosing by remember { mutableStateOf(false) }
|
var choosing by remember { mutableStateOf(false) }
|
||||||
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
|
var confirmForget by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Change match") },
|
title = { Text("Change match") },
|
||||||
@@ -705,12 +783,7 @@ internal fun ChangeMatchDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
QuietButton(onClick = {
|
QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
|
||||||
scope.launch {
|
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
|
||||||
.onSuccess { onChanged() }.onFailure(onError)
|
|
||||||
}
|
|
||||||
}) { Text("Forget match") }
|
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -142,6 +142,28 @@ internal fun QuietButton(
|
|||||||
content = content,
|
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.
|
* How far along something is, drawn in the accent.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ object AppearanceState {
|
|||||||
.onSuccess { current.value = it }
|
.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. */
|
/** Repaint immediately, before the write that stores the choice lands. */
|
||||||
fun apply(palette: PaletteRecord) {
|
fun apply(palette: PaletteRecord) {
|
||||||
current.value = palette
|
current.value = palette
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
@@ -9,7 +9,7 @@ class AccountUiTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `the sync line says how long ago and what came in`() {
|
fun `the sync line says how long ago and what came in`() {
|
||||||
val minute = 60_000L
|
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 just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||||
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -21,7 +21,7 @@ class AccountUiTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `a failed sync is said in place of when it last worked`() {
|
fun `a failed sync is said in place of when it last worked`() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Watch history did not sync: offline",
|
"Did not sync: offline",
|
||||||
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class HistoryTest {
|
|||||||
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||||
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
||||||
franchise = emptyList(),
|
franchise = emptyList(),
|
||||||
|
continueIndex = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
|
|||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
import uniffi.pstr_android.ArrangementRecord
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import uniffi.pstr_android.MetadataSettingsRecord
|
||||||
import uniffi.pstr_android.OfflineRecord
|
import uniffi.pstr_android.OfflineRecord
|
||||||
import uniffi.pstr_android.PaletteRecord
|
import uniffi.pstr_android.PaletteRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
@@ -79,18 +81,23 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the library on a phone`() = capture("library-phone") {
|
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())
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
fun `the library on a tablet`() = capture("library-tablet") {
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -116,7 +123,7 @@ class ScreenshotTest {
|
|||||||
// reads it from, so the scale is set there.
|
// reads it from, so the scale is set there.
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,22 +144,22 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the shares page`() = capture("shares-phone") {
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the settings page`() = capture("settings-phone") {
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the playback settings`() = capture("settings-playback") {
|
fun `the playback settings`() = capture("settings-playback") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the storage settings`() = capture("settings-storage") {
|
fun `the storage settings`() = capture("settings-storage") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -264,6 +271,9 @@ class ScreenshotTest {
|
|||||||
nextEpisode = null,
|
nextEpisode = null,
|
||||||
nextAiringAt = null,
|
nextAiringAt = null,
|
||||||
franchise = emptyList(),
|
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(
|
val SERIES = title(
|
||||||
@@ -290,6 +300,16 @@ class ScreenshotTest {
|
|||||||
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
.copy(kind = TitleType.FILM, year = 1988u)
|
.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(
|
val LIBRARY = AppUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
titles = listOf(
|
titles = listOf(
|
||||||
@@ -306,9 +326,17 @@ class ScreenshotTest {
|
|||||||
},
|
},
|
||||||
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
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(
|
shares = listOf(
|
||||||
ShareRecord("anime", "anime", true),
|
ShareRecord(
|
||||||
ShareRecord("films", "films", false),
|
"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),
|
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: 49 KiB After Width: | Height: | Size: 68 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 |
@@ -25,6 +25,8 @@ tracing-subscriber.workspace = true
|
|||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
egui-phosphor.workspace = true
|
egui-phosphor.workspace = true
|
||||||
|
# The parser egui reads fonts with, to check a system font before handing it over.
|
||||||
|
skrifa.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -39,8 +41,6 @@ tao.workspace = true
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
embed-resource.workspace = true
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
skrifa.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus.workspace = true
|
dbus.workspace = true
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// What the settings page offers for the streaming cache, in GiB.
|
/// What the settings page offers for the streaming cache, in GiB.
|
||||||
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
// A file from an older version, or edited by hand, missing a field: the rest
|
// A file from an older version, or edited by hand, missing a field: the rest
|
||||||
// still counts, and the missing one takes its default.
|
// still counts, and the missing one takes its default.
|
||||||
@@ -21,6 +24,15 @@ pub struct DesktopPrefs {
|
|||||||
pub hardware_decoding: bool,
|
pub hardware_decoding: bool,
|
||||||
/// How much decrypted video the disk cache may keep.
|
/// How much decrypted video the disk cache may keep.
|
||||||
pub cache_gib: u32,
|
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 {
|
impl Default for DesktopPrefs {
|
||||||
@@ -28,11 +40,23 @@ impl Default for DesktopPrefs {
|
|||||||
Self {
|
Self {
|
||||||
hardware_decoding: true,
|
hardware_decoding: true,
|
||||||
cache_gib: 4,
|
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 {
|
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 {
|
pub fn cache_bytes(&self) -> u64 {
|
||||||
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||||
}
|
}
|
||||||
@@ -93,6 +117,17 @@ mod tests {
|
|||||||
assert!(prefs.hardware_decoding);
|
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]
|
#[test]
|
||||||
fn an_unparseable_file_is_never_overwritten() {
|
fn an_unparseable_file_is_never_overwritten() {
|
||||||
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ use parking_lot::Mutex;
|
|||||||
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||||
use pstr_core::appearance::Appearance;
|
use pstr_core::appearance::Appearance;
|
||||||
use pstr_core::catalog::{
|
use pstr_core::catalog::{
|
||||||
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
|
||||||
};
|
};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
|
use pstr_core::profile::ProfileChanges;
|
||||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||||
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||||
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
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.
|
/// The share the form asked for is stored; its crawl is starting.
|
||||||
ShareAdded(String),
|
ShareAdded(String),
|
||||||
/// The share the form asked for was refused, with why. Nothing was crawled.
|
/// 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.
|
/// Shares opened; playback is possible from here.
|
||||||
Connected {
|
Connected {
|
||||||
/// Shares that did not open, with why.
|
/// Shares that did not open, with why.
|
||||||
@@ -192,8 +197,15 @@ pub enum Event {
|
|||||||
share_id: String,
|
share_id: String,
|
||||||
nodes: usize,
|
nodes: usize,
|
||||||
files: usize,
|
files: usize,
|
||||||
|
/// Files no earlier crawl had seen.
|
||||||
|
added: u64,
|
||||||
seconds: f64,
|
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.
|
/// A crawl is under way: which share, and how many nodes so far.
|
||||||
CrawlProgress { share: String, found: usize },
|
CrawlProgress { share: String, found: usize },
|
||||||
/// Every requested crawl is done.
|
/// Every requested crawl is done.
|
||||||
@@ -215,12 +227,16 @@ pub enum Event {
|
|||||||
EpisodeMetadata(HashMap<String, EpisodeGuide>),
|
EpisodeMetadata(HashMap<String, EpisodeGuide>),
|
||||||
/// The enrichment settings changed, on disk.
|
/// The enrichment settings changed, on disk.
|
||||||
MetadataConfig(MetadataConfig),
|
MetadataConfig(MetadataConfig),
|
||||||
|
/// Another device's appearance arrived through sync.
|
||||||
|
Appearance(Appearance),
|
||||||
/// A matching run finished, with how it went.
|
/// A matching run finished, with how it went.
|
||||||
Matched {
|
Matched {
|
||||||
matched: usize,
|
matched: usize,
|
||||||
unmatched: usize,
|
unmatched: usize,
|
||||||
failed: usize,
|
failed: usize,
|
||||||
},
|
},
|
||||||
|
/// A matching run has work to do — one asked for, or one after a crawl.
|
||||||
|
MatchStarted,
|
||||||
/// Every requested match is done.
|
/// Every requested match is done.
|
||||||
MatchFinished,
|
MatchFinished,
|
||||||
/// What the provider answered a hand search with, unscored and in its own
|
/// What the provider answered a hand search with, unscored and in its own
|
||||||
@@ -279,7 +295,7 @@ pub enum Event {
|
|||||||
SignInFailed(String),
|
SignInFailed(String),
|
||||||
/// Watch history synced with the account's Drive.
|
/// Watch history synced with the account's Drive.
|
||||||
Synced(SyncReport),
|
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.
|
/// timer, and an offline evening would be a stack of them.
|
||||||
SyncFailed(String),
|
SyncFailed(String),
|
||||||
/// The account's top-level places, for the Drive browser.
|
/// The account's top-level places, for the Drive browser.
|
||||||
@@ -293,6 +309,16 @@ pub enum Event {
|
|||||||
BrowseFailed(String),
|
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.
|
/// Where the Proton account stands.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub enum AccountStatus {
|
pub enum AccountStatus {
|
||||||
@@ -397,6 +423,14 @@ const DOWNLOAD_CONCURRENCY: usize = 3;
|
|||||||
/// launch and whenever a player stops.
|
/// launch and whenever a player stops.
|
||||||
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
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.
|
/// Enrichment as currently configured.
|
||||||
struct Enrichment {
|
struct Enrichment {
|
||||||
config: MetadataConfig,
|
config: MetadataConfig,
|
||||||
@@ -640,20 +674,63 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a share, then open and crawl it — which is what "add" means to
|
/// Add a share, then open and crawl it — which is what "add" means to
|
||||||
/// 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>) {
|
pub fn add_share(&self, name: String, url: String, password: Option<String>) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
let share = match engine.store.add(&name, &url, password.as_deref()) {
|
let share = match engine
|
||||||
Ok(share) => share,
|
.store
|
||||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
.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.emit(Event::ShareAdded(share.name.clone()));
|
||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
// The open connection predates the share, and crawling it through
|
// The open connection predates the share, and crawling it through
|
||||||
// that would find it "not open".
|
// that would find it "not open".
|
||||||
engine.connection.lock().take();
|
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) {
|
let share = match engine.store.add_folder(&name, folder) {
|
||||||
Ok(share) => share,
|
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.emit(Event::ShareAdded(share.name.clone()));
|
||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
engine.connection.lock().take();
|
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();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
match sync.sync(&account, &engine.catalog).await {
|
match sync.sync(&account, &engine.catalog, &engine.store).await {
|
||||||
Ok(report) => {
|
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.load_library();
|
||||||
}
|
}
|
||||||
engine.emit(Event::Synced(report));
|
engine.emit(Event::Synced(report));
|
||||||
@@ -875,8 +953,9 @@ impl Engine {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let catalog = Arc::clone(&self.catalog);
|
let catalog = Arc::clone(&self.catalog);
|
||||||
|
let store = Arc::clone(&self.store);
|
||||||
self.runtime.block_on(async move {
|
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(Ok(_)) => {}
|
||||||
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
||||||
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
|
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) {
|
fn sync_periodically(&self) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
@@ -929,6 +1060,48 @@ impl Engine {
|
|||||||
pub fn remove_share(&self, id: String) {
|
pub fn remove_share(&self, id: String) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
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
|
let keys: Vec<_> = engine
|
||||||
.downloads
|
.downloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -971,7 +1144,7 @@ impl Engine {
|
|||||||
if let Err(error) = engine.catalog.lock().remove_share(&id) {
|
if let Err(error) = engine.catalog.lock().remove_share(&id) {
|
||||||
return engine.fail("drop that share's catalog rows", error);
|
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);
|
return engine.fail("remove that share", error);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -983,9 +1156,7 @@ impl Engine {
|
|||||||
engine.load_shares();
|
engine.load_shares();
|
||||||
// Its clients are stale now; the next action reopens what is left.
|
// Its clients are stale now; the next action reopens what is left.
|
||||||
engine.connection.lock().take();
|
engine.connection.lock().take();
|
||||||
engine.load_library();
|
}
|
||||||
engine.emit(Event::Status("removed".into()));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ connection
|
// ------------------------------------------------------------ connection
|
||||||
@@ -1173,18 +1344,65 @@ impl Engine {
|
|||||||
pub fn crawl(&self, share: Option<String>) {
|
pub fn crawl(&self, share: Option<String>) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
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 {
|
let Some((library, _)) = self.ensure_connected().await else {
|
||||||
self.emit(Event::CrawlFinished);
|
self.emit(Event::CrawlFinished);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let targets: Vec<String> = match share {
|
let targets: Vec<String> = match shares {
|
||||||
Some(id) => vec![id],
|
Some(ids) => ids,
|
||||||
None => library.share_ids().map(str::to_string).collect(),
|
None => library.share_ids().map(str::to_string).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1194,10 +1412,36 @@ impl Engine {
|
|||||||
futures::future::join_all(
|
futures::future::join_all(
|
||||||
targets
|
targets
|
||||||
.into_iter()
|
.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;
|
.await;
|
||||||
self.emit(Event::CrawlFinished);
|
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
|
/// Stop every crawl still listing. Shares already past their listing
|
||||||
@@ -1212,6 +1456,7 @@ impl Engine {
|
|||||||
library: &SharedLibrary,
|
library: &SharedLibrary,
|
||||||
share_id: String,
|
share_id: String,
|
||||||
mut cancel: tokio::sync::watch::Receiver<u64>,
|
mut cancel: tokio::sync::watch::Receiver<u64>,
|
||||||
|
quiet: bool,
|
||||||
) {
|
) {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let name = self
|
let name = self
|
||||||
@@ -1241,10 +1486,24 @@ impl Engine {
|
|||||||
// share's rows and its stale downloads, and stopping halfway through
|
// share's rows and its stale downloads, and stopping halfway through
|
||||||
// that would leave the two disagreeing.
|
// that would leave the two disagreeing.
|
||||||
let nodes = tokio::select! {
|
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,
|
Ok(nodes) => nodes,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.fail(&format!("crawl {share_id}"), error);
|
if let Err(error) = self
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.record_crawl_failure(&share_id, &error.describe())
|
||||||
|
{
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1254,6 +1513,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let rows = build_rows(&share_id, &nodes);
|
let rows = build_rows(&share_id, &nodes);
|
||||||
|
let sidecars = build_sidecars(&share_id, &nodes);
|
||||||
let files = rows.len();
|
let files = rows.len();
|
||||||
|
|
||||||
// A writer opened before the crawl may be producing the revision
|
// A writer opened before the crawl may be producing the revision
|
||||||
@@ -1315,7 +1575,7 @@ impl Engine {
|
|||||||
for (link_id, _) in &stale {
|
for (link_id, _) in &stale {
|
||||||
catalog.remove_offline_file(&share_id, link_id)?;
|
catalog.remove_offline_file(&share_id, link_id)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
catalog.replace_sidecars(&share_id, &sidecars)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
if let Err(error) = stored {
|
if let Err(error) = stored {
|
||||||
@@ -1323,12 +1583,22 @@ impl Engine {
|
|||||||
return;
|
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 {
|
self.emit(Event::Crawled {
|
||||||
share_id,
|
share_id,
|
||||||
nodes: nodes.len(),
|
nodes: nodes.len(),
|
||||||
files,
|
files,
|
||||||
|
added,
|
||||||
seconds: started.elapsed().as_secs_f64(),
|
seconds: started.elapsed().as_secs_f64(),
|
||||||
|
quiet,
|
||||||
});
|
});
|
||||||
|
self.load_crawls();
|
||||||
self.load_library();
|
self.load_library();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1419,19 +1689,26 @@ impl Engine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some((_, source)) = engine.ensure_connected().await else {
|
let Some((library, source)) = engine.ensure_connected().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let uid = node_uid(&target.volume_id, &target.link_id);
|
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 => {
|
Ok(stream) if stream.size() == 0 => {
|
||||||
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
||||||
}
|
}
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
// Before mpv is even built: building it takes the UI thread
|
// Before mpv is even built: building it takes the UI thread
|
||||||
// a moment, and the network can spend that moment on the
|
// a moment, and the network can spend that moment on the
|
||||||
// two reads every file opens with.
|
// two reads every file opens with — and, resuming, on the
|
||||||
stream.warm_edges();
|
// 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 {
|
engine.emit(Event::PlaybackReady {
|
||||||
target: Box::new(target),
|
target: Box::new(target),
|
||||||
stream,
|
stream,
|
||||||
@@ -1442,6 +1719,69 @@ impl Engine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the subtitle files paired with `target`, for the player to add
|
||||||
|
/// once mpv has the video.
|
||||||
|
///
|
||||||
|
/// Through the session already open: a local copy played without one goes
|
||||||
|
/// without its subtitles rather than waiting on a handshake. Each is
|
||||||
|
/// warmed — most are one block — so mpv reads it from the cache. One that
|
||||||
|
/// fails is left out quietly; the video plays either way.
|
||||||
|
pub async fn open_sidecars(
|
||||||
|
&self,
|
||||||
|
target: &PlaybackTarget,
|
||||||
|
) -> Vec<(pstr_core::catalog::Sidecar, VideoStream)> {
|
||||||
|
let sidecars = self
|
||||||
|
.catalog
|
||||||
|
.lock()
|
||||||
|
.sidecars(&target.share_id, &target.link_id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if sidecars.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Some((library, source)) = self.opened() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let opening = sidecars.into_iter().map(|sidecar| {
|
||||||
|
let library = Arc::clone(&library);
|
||||||
|
let source = source.clone();
|
||||||
|
async move {
|
||||||
|
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
|
||||||
|
match library
|
||||||
|
.reauthenticating(&sidecar.share_id, || source.open(&sidecar.share_id, &uid))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(stream) => {
|
||||||
|
stream.warm_edges();
|
||||||
|
Some((sidecar, stream))
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "open subtitle {}", sidecar.name);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
futures::future::join_all(opening)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// 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
|
/// For the episode after the one playing, so that autoplay — or a click on
|
||||||
@@ -1464,10 +1804,13 @@ impl Engine {
|
|||||||
if offline {
|
if offline {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(source) = engine.opened().map(|(_, source)| source) else {
|
let Some((library, source)) = engine.opened() else {
|
||||||
return;
|
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(),
|
Ok(stream) => stream.warm_edges(),
|
||||||
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
|
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
|
||||||
}
|
}
|
||||||
@@ -1800,14 +2143,14 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.update_download(&key, |item| item.state = DownloadState::Running);
|
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");
|
anyhow::bail!("share is not connected");
|
||||||
};
|
};
|
||||||
let stream = source
|
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||||
.open_for_copy(
|
let stream = library
|
||||||
&target.share_id,
|
.reauthenticating(&target.share_id, || {
|
||||||
&node_uid(&target.volume_id, &target.link_id),
|
source.open_for_copy(&target.share_id, &uid)
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let revision_id = stream.revision_id().to_owned();
|
let revision_id = stream.revision_id().to_owned();
|
||||||
let block_sizes = stream.block_sizes().to_vec();
|
let block_sizes = stream.block_sizes().to_vec();
|
||||||
@@ -2233,13 +2576,18 @@ impl Engine {
|
|||||||
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
|
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
engine.run_match(titles, force).await;
|
engine.run_match(titles, force, false).await;
|
||||||
engine.emit(Event::MatchFinished);
|
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 {
|
let Some(service) = self.metadata_service().await else {
|
||||||
|
if automatic {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return self.emit(Event::Error(self.no_service_reason()));
|
return self.emit(Event::Error(self.no_service_reason()));
|
||||||
};
|
};
|
||||||
let provider = service.provider();
|
let provider = service.provider();
|
||||||
@@ -2264,8 +2612,12 @@ impl Engine {
|
|||||||
force,
|
force,
|
||||||
);
|
);
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
|
if automatic {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return self.emit(Event::Status("everything is already matched".into()));
|
return self.emit(Event::Status("everything is already matched".into()));
|
||||||
}
|
}
|
||||||
|
self.emit(Event::MatchStarted);
|
||||||
self.emit(Event::Status(format!(
|
self.emit(Event::Status(format!(
|
||||||
"matching {} titles against {}…",
|
"matching {} titles against {}…",
|
||||||
pending.len(),
|
pending.len(),
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
|
|||||||
use pstr_core::library::{Episode, Title};
|
use pstr_core::library::{Episode, Title};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_player::{
|
use pstr_player::{
|
||||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
|
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
|
||||||
VideoOutput,
|
TrackKind, VideoOutput,
|
||||||
};
|
};
|
||||||
use pstr_stream::VideoStream;
|
use pstr_stream::VideoStream;
|
||||||
|
|
||||||
@@ -338,6 +338,18 @@ impl Playback {
|
|||||||
(end > self.position + 0.5).then_some((label, end))
|
(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
|
/// 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
|
/// the file — what "the episode is over" means when a file states its
|
||||||
/// chapters.
|
/// chapters.
|
||||||
@@ -504,7 +516,11 @@ fn run(
|
|||||||
orders: std::sync::mpsc::Receiver<Command>,
|
orders: std::sync::mpsc::Receiver<Command>,
|
||||||
finished: Arc<AtomicBool>,
|
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,
|
Ok(handle) => handle,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
||||||
@@ -514,10 +530,18 @@ fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Opened while mpv opens the video, and added once it has: an external
|
||||||
|
// track belongs to the file loaded at the time.
|
||||||
|
let mut sidecars = Some(engine.runtime().spawn({
|
||||||
|
let engine = engine.clone();
|
||||||
|
let target = target.clone();
|
||||||
|
async move { engine.open_sidecars(&target).await }
|
||||||
|
}));
|
||||||
|
let mut subtitles: Vec<StreamHandle> = Vec::new();
|
||||||
|
|
||||||
let mut position = target.resume_at.unwrap_or(0.0);
|
let mut position = target.resume_at.unwrap_or(0.0);
|
||||||
let mut duration: Option<f64> = None;
|
let mut duration: Option<f64> = None;
|
||||||
let mut watched = false;
|
let mut watched = false;
|
||||||
let mut resumed = target.resume_at.is_none();
|
|
||||||
let mut last_save = std::time::Instant::now();
|
let mut last_save = std::time::Instant::now();
|
||||||
|
|
||||||
'playback: loop {
|
'playback: loop {
|
||||||
@@ -543,6 +567,10 @@ fn run(
|
|||||||
match &event {
|
match &event {
|
||||||
PlayerEvent::FileLoaded => {
|
PlayerEvent::FileLoaded => {
|
||||||
duration = player.duration();
|
duration = player.duration();
|
||||||
|
if let Some(opening) = sidecars.take() {
|
||||||
|
let opened = engine.runtime().block_on(opening).unwrap_or_default();
|
||||||
|
subtitles = add_sidecars(&engine, &target, &player, opened);
|
||||||
|
}
|
||||||
// The first moment there is a track list to read: mpv has
|
// The first moment there is a track list to read: mpv has
|
||||||
// demuxed the file and made its own default selection. Put the
|
// demuxed the file and made its own default selection. Put the
|
||||||
// title's own choice over it before the menus are told, so they
|
// title's own choice over it before the menus are told, so they
|
||||||
@@ -550,14 +578,16 @@ fn run(
|
|||||||
if let Some(show) = target.track_prefs.as_deref() {
|
if let Some(show) = target.track_prefs.as_deref() {
|
||||||
restore_tracks(&player, show);
|
restore_tracks(&player, show);
|
||||||
}
|
}
|
||||||
emit_tracks(&engine, id, &player);
|
// A subtitle track picked by name stays; one mpv picked by
|
||||||
// Only now is there a timeline to seek within.
|
// language may be the signs and songs, not the dialogue.
|
||||||
if !resumed && let Some(at) = target.resume_at {
|
let picked = target
|
||||||
resumed = true;
|
.track_prefs
|
||||||
if let Err(error) = player.seek_to(at) {
|
.as_deref()
|
||||||
tracing::warn!("resume at {at}: {error}");
|
.is_some_and(|show| show.subtitle_title.is_some());
|
||||||
}
|
if !picked {
|
||||||
|
prefer_full_subtitles(&player);
|
||||||
}
|
}
|
||||||
|
emit_tracks(&engine, id, &player);
|
||||||
}
|
}
|
||||||
PlayerEvent::Duration(value) => duration = Some(*value),
|
PlayerEvent::Duration(value) => duration = Some(*value),
|
||||||
PlayerEvent::Position(value) => {
|
PlayerEvent::Position(value) => {
|
||||||
@@ -599,6 +629,7 @@ fn run(
|
|||||||
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
||||||
// OpenGL context its render context was built against.
|
// OpenGL context its render context was built against.
|
||||||
drop(handle);
|
drop(handle);
|
||||||
|
drop(subtitles);
|
||||||
drop(player);
|
drop(player);
|
||||||
|
|
||||||
engine.release(
|
engine.release(
|
||||||
@@ -713,6 +744,57 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play the dialogue rather than the signs and songs mpv picked by language.
|
||||||
|
/// Not remembered: it is the choice mpv should have made, not the viewer's.
|
||||||
|
fn prefer_full_subtitles(player: &Player) {
|
||||||
|
if let Some(track) = pstr_player::full_subtitles_instead(&player.tracks())
|
||||||
|
&& let Err(error) = player.select_track(TrackKind::Subtitle, Some(track))
|
||||||
|
{
|
||||||
|
tracing::warn!("select dialogue subtitle {track}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand mpv the subtitle files paired with the video it has just loaded.
|
||||||
|
///
|
||||||
|
/// One is turned on only when subtitles are on and the video has none of its
|
||||||
|
/// own playing — a file's own track was chosen by name and language already,
|
||||||
|
/// and an external one is the fallback, not an override.
|
||||||
|
fn add_sidecars(
|
||||||
|
engine: &Engine,
|
||||||
|
target: &PlaybackTarget,
|
||||||
|
player: &Player,
|
||||||
|
opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>,
|
||||||
|
) -> Vec<StreamHandle> {
|
||||||
|
let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref());
|
||||||
|
let playing = player
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.any(|track| track.kind == TrackKind::Subtitle && track.selected);
|
||||||
|
let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
|
||||||
|
let select = (config.subtitles && !playing)
|
||||||
|
.then(|| {
|
||||||
|
pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref())
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
sidecars
|
||||||
|
.iter()
|
||||||
|
.zip(streams)
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, (sidecar, stream))| {
|
||||||
|
let title = sidecar.title.as_deref().unwrap_or("External");
|
||||||
|
player
|
||||||
|
.add_subtitle(
|
||||||
|
stream,
|
||||||
|
title,
|
||||||
|
sidecar.language.as_deref(),
|
||||||
|
select == Some(index),
|
||||||
|
)
|
||||||
|
.inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the UI what the file contains and what of it is playing.
|
/// Tell the UI what the file contains and what of it is playing.
|
||||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||||
engine.emit(Event::Player {
|
engine.emit(Event::Player {
|
||||||
|
|||||||
@@ -248,15 +248,40 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
|||||||
let Some((path, index)) = system_cjk_font() else {
|
let Some((path, index)) = system_cjk_font() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(bytes) = std::fs::read(path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(data) = cjk_font_data(bytes, index) else {
|
||||||
|
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
|
||||||
data.index = index;
|
|
||||||
ctx.set_fonts(font_definitions(Some(data)));
|
ctx.set_fonts(font_definitions(Some(data)));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fallback font, if egui can read it.
|
||||||
|
///
|
||||||
|
/// egui panics on a font it cannot parse, and this one comes from whatever the
|
||||||
|
/// system has installed. So it is parsed here first, the same way egui does,
|
||||||
|
/// and a font that fails is left out rather than taking the app down.
|
||||||
|
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
|
||||||
|
skrifa::FontRef::from_index(&bytes, index).ok()?;
|
||||||
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
|
data.index = index;
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face within a font collection that fontconfig's `%{index}` names.
|
||||||
|
///
|
||||||
|
/// Fontconfig packs a variable font's named instance into the high 16 bits —
|
||||||
|
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
|
||||||
|
/// instance is not a face, and passing it to egui as one stopped the app at
|
||||||
|
/// start.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn fontconfig_face(index: u32) -> u32 {
|
||||||
|
index & 0xFFFF
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
let output = std::process::Command::new("fc-match")
|
let output = std::process::Command::new("fc-match")
|
||||||
@@ -269,6 +294,7 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|||||||
let index = lines
|
let index = lines
|
||||||
.next()
|
.next()
|
||||||
.and_then(|value| value.parse().ok())
|
.and_then(|value| value.parse().ok())
|
||||||
|
.map(fontconfig_face)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
path.is_file().then_some((path, index))
|
path.is_file().then_some((path, index))
|
||||||
}
|
}
|
||||||
@@ -855,6 +881,20 @@ const RAMP_STOPS: usize = 64;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
||||||
|
assert_eq!(fontconfig_face(262_144), 0);
|
||||||
|
assert_eq!(fontconfig_face(0x0002_0001), 1);
|
||||||
|
assert_eq!(fontconfig_face(2), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fallback_font_egui_cannot_read_is_left_out() {
|
||||||
|
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
|
||||||
|
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
|
||||||
|
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||||
use skrifa::MetadataProvider as _;
|
use skrifa::MetadataProvider as _;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::app::Action;
|
|||||||
use crate::engine::AccountStatus;
|
use crate::engine::AccountStatus;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// What the account section holds between frames.
|
/// What the account section holds between frames.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -133,9 +134,10 @@ impl DriveBrowser {
|
|||||||
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Proton account");
|
ui::section(ui, "Proton account");
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Optional. Signed in, your watch history is kept in your own Drive and every device \
|
"Optional. Signed in, your shares, settings and watch history are kept in your own \
|
||||||
resumes where another left off, and folders of your Drive — your files, other \
|
Drive, so every device shows the same library and resumes where another left off, \
|
||||||
devices, what was shared with you — can be added to the library.",
|
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);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
@@ -281,9 +283,11 @@ fn signed_in(
|
|||||||
.strong(),
|
.strong(),
|
||||||
);
|
);
|
||||||
let status = match (&panel.sync_error, &panel.synced) {
|
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, 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));
|
ui.label(ui::muted(status));
|
||||||
});
|
});
|
||||||
@@ -312,13 +316,7 @@ fn signed_in(
|
|||||||
|
|
||||||
/// "Synced 3 min ago · 2 positions from other devices".
|
/// "Synced 3 min ago · 2 positions from other devices".
|
||||||
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
|
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
|
||||||
let ago = (now - at).max(0);
|
let mut line = format!("Synced {}", ui::ago(at, now));
|
||||||
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}");
|
|
||||||
if report.applied > 0 {
|
if report.applied > 0 {
|
||||||
line.push_str(&format!(
|
line.push_str(&format!(
|
||||||
" · {} from other devices",
|
" · {} from other devices",
|
||||||
@@ -382,7 +380,7 @@ pub fn browser(
|
|||||||
if !browser.trail.is_empty()
|
if !browser.trail.is_empty()
|
||||||
&& ui
|
&& ui
|
||||||
.button(egui_phosphor::regular::ARROW_LEFT)
|
.button(egui_phosphor::regular::ARROW_LEFT)
|
||||||
.on_hover_text("Up a folder")
|
.hint("Up a folder")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
browser.up(actions);
|
browser.up(actions);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Inspectable desktop download queue.
|
//! Inspectable desktop download queue.
|
||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::{Action, Page};
|
||||||
use crate::engine::{DownloadItem, DownloadState};
|
use crate::engine::{DownloadItem, DownloadState};
|
||||||
use crate::{theme, ui};
|
use crate::{theme, ui};
|
||||||
|
|
||||||
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DownloadState::Completed => {
|
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
|
if ui
|
||||||
.button("Delete")
|
.button("Delete")
|
||||||
.on_hover_text("Delete the offline copy; keep the online source")
|
.on_hover_text("Delete the offline copy; keep the online source")
|
||||||
.clicked()
|
.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::app::{Action, Page};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
use crate::ui::{self, Art};
|
use crate::ui::{self, Art};
|
||||||
|
|
||||||
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||||
@@ -161,7 +162,7 @@ fn row(
|
|||||||
)
|
)
|
||||||
.frame(false),
|
.frame(false),
|
||||||
)
|
)
|
||||||
.on_hover_text(if episode.is_watched() {
|
.hint(if episode.is_watched() {
|
||||||
"Remove from history — marks it unwatched"
|
"Remove from history — marks it unwatched"
|
||||||
} else {
|
} else {
|
||||||
"Remove from history — forgets where you stopped"
|
"Remove from history — forgets where you stopped"
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ pub struct Shelves<'a> {
|
|||||||
pub search: &'a str,
|
pub search: &'a str,
|
||||||
/// The grid's shape.
|
/// The grid's shape.
|
||||||
pub tiles: Tiles,
|
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>) {
|
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,
|
loaded,
|
||||||
search,
|
search,
|
||||||
tiles,
|
tiles,
|
||||||
|
crawl,
|
||||||
|
offer_metadata,
|
||||||
} = shelves;
|
} = shelves;
|
||||||
if !loaded {
|
if !loaded {
|
||||||
return placeholder(ui);
|
return placeholder(ui);
|
||||||
}
|
}
|
||||||
if library.is_empty() {
|
if library.is_empty() {
|
||||||
return empty_state(ui, actions);
|
return empty_state(ui, crawl, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pick = |indices: &[usize]| -> Vec<&Title> {
|
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()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
if !searching && offer_metadata {
|
||||||
|
metadata_offer(ui, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
if !searching {
|
if !searching {
|
||||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||||
hero(ui, art, title, actions);
|
hero(ui, art, title, actions);
|
||||||
@@ -304,8 +315,16 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
|||||||
}
|
}
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
let mut grouped = view.grouped;
|
let mut grouped = view.grouped;
|
||||||
if ui
|
let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK);
|
||||||
.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 {
|
.on_hover_text(if view.grouped {
|
||||||
"One tile per franchise. Click to show every title"
|
"One tile per franchise. Click to show every title"
|
||||||
} else {
|
} else {
|
||||||
@@ -316,17 +335,8 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
|||||||
actions.push(Action::SetGrouped(grouped));
|
actions.push(Action::SetGrouped(grouped));
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
if let Some(filter) = ui::widgets::segmented(
|
let filters = Filter::ALL.map(|filter| (filter, filter.label()));
|
||||||
ui,
|
if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
|
||||||
view.filter,
|
|
||||||
&[
|
|
||||||
(Filter::All, "All"),
|
|
||||||
(Filter::Series, "Series"),
|
|
||||||
(Filter::Films, "Films"),
|
|
||||||
(Filter::Watching, "Watching"),
|
|
||||||
(Filter::Unwatched, "Not started"),
|
|
||||||
],
|
|
||||||
) {
|
|
||||||
actions.push(Action::SetShelf(filter, view.sort));
|
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(
|
fn continue_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
@@ -395,21 +405,17 @@ fn continue_row(
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
for title in titles {
|
for title in titles {
|
||||||
let Some(episode) = title.resume() else {
|
let Some(episode) = title.continue_with() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let remaining = episode
|
let remaining = match episode.resume_at() {
|
||||||
.watch
|
Some(at) => episode
|
||||||
.and_then(|watch| watch.duration_secs)
|
.watch
|
||||||
.map(|duration| {
|
.and_then(|watch| watch.duration_secs)
|
||||||
format!(
|
.map(|duration| format!("{} left", ui::format_time(duration - at)))
|
||||||
"{} left",
|
.unwrap_or_else(|| episode.label()),
|
||||||
ui::format_time(
|
None => format!("Next: {}", episode.label()),
|
||||||
duration - episode.watch.map_or(0.0, |w| w.position_secs)
|
};
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| episode.label());
|
|
||||||
|
|
||||||
let picture = art.of(title);
|
let picture = art.of(title);
|
||||||
let response = ui::card(
|
let response = ui::card(
|
||||||
@@ -550,7 +556,7 @@ fn tile_menu(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if continuing
|
if continuing
|
||||||
&& let Some(episode) = title.resume()
|
&& let Some(episode) = title.continue_with()
|
||||||
&& ui
|
&& ui
|
||||||
.button("Remove from Continue watching")
|
.button("Remove from Continue watching")
|
||||||
.on_hover_text("Forget where you stopped in it")
|
.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.vertical_centered(|ui| {
|
||||||
ui.add_space(120.0);
|
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(
|
ui.label(
|
||||||
theme::Role::Title
|
theme::Role::Title
|
||||||
.rich("Nothing in the library yet")
|
.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);
|
ui.add_space(theme::space::XL);
|
||||||
if ui::accent_button(ui, "Add a share").clicked() {
|
if ui::accent_button(ui, "Add a share").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::OpenShareForm);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,12 +130,29 @@ fn current_row(
|
|||||||
)));
|
)));
|
||||||
// Only worth offering when there is something to undo: with no
|
// Only worth offering when there is something to undo: with no
|
||||||
// row stored, "clear" would delete nothing.
|
// 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")
|
.button("Clear")
|
||||||
.on_hover_text("Forget this match and let the next match run decide again")
|
.on_hover_text("Forget this match and let the next match run decide again")
|
||||||
.clicked()
|
.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, _)) => {
|
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::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use widgets::Named;
|
||||||
|
|
||||||
/// Everything the pages need to draw one title's picture.
|
/// 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}")
|
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.
|
/// `1:03:47`, or `4:12` for anything under an hour.
|
||||||
pub fn format_time(seconds: f64) -> String {
|
pub fn format_time(seconds: f64) -> String {
|
||||||
if !seconds.is_finite() || seconds < 0.0 {
|
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
|
if response
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
.on_hover_text("Clear (Esc)")
|
.hint("Clear (Esc)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
search.clear();
|
search.clear();
|
||||||
@@ -374,6 +388,19 @@ pub enum Answer {
|
|||||||
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
||||||
/// the click.
|
/// the click.
|
||||||
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
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 mut answer = Answer::Pending;
|
||||||
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||||
.frame(
|
.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.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if danger_button(ui, verb).clicked() {
|
let yes = if destroys {
|
||||||
|
danger_button(ui, verb)
|
||||||
|
} else {
|
||||||
|
accent_button(ui, verb)
|
||||||
|
};
|
||||||
|
if yes.clicked() {
|
||||||
answer = Answer::Confirmed;
|
answer = Answer::Confirmed;
|
||||||
}
|
}
|
||||||
if ui.button("Cancel").clicked() {
|
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 (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||||
|
let response = response.named(text);
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -595,6 +628,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
|
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||||
|
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
|
// 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.
|
// early return below would skip a tile that is just off screen.
|
||||||
if response.gained_focus() {
|
if response.gained_focus() {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
|
|||||||
use crate::app::{Action, Adjacent};
|
use crate::app::{Action, Adjacent};
|
||||||
use crate::playback::{Command, Playback};
|
use crate::playback::{Command, Playback};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
use crate::ui::{self, transport::Neighbours};
|
use crate::ui::{self, transport::Neighbours};
|
||||||
|
|
||||||
/// How long the controls stay up after the pointer stops moving.
|
/// How long the controls stay up after the pointer stops moving.
|
||||||
@@ -139,6 +140,9 @@ pub struct UpNextCard {
|
|||||||
pub caption: String,
|
pub caption: String,
|
||||||
/// Its still, once there is one.
|
/// Its still, once there is one.
|
||||||
pub still: Option<egui::TextureHandle>,
|
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 {
|
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.
|
/// Draw the picture and its controls.
|
||||||
///
|
///
|
||||||
/// `playback` is `None` in the gap between one episode ending and the next
|
/// `playback` is `None` until the file has opened — on a first click, and in
|
||||||
/// having opened — the page stays, because that is where the viewer means to
|
/// the gap between one episode ending and the next — and `opening` is what the
|
||||||
/// be, and `opening` is what it says while they wait.
|
/// page says meanwhile.
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
frame: &mut eframe::Frame,
|
frame: &mut eframe::Frame,
|
||||||
playback: Option<&mut Playback>,
|
playback: Option<&mut Playback>,
|
||||||
opening: Option<&str>,
|
opening: Option<Waiting<'_>>,
|
||||||
chrome_state: Chrome<'_>,
|
chrome_state: Chrome<'_>,
|
||||||
actions: &mut Vec<Action>,
|
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
|
/// 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
|
/// seconds and a black screen with no controls is indistinguishable from a
|
||||||
/// hang.
|
/// 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(
|
ui.painter().text(
|
||||||
rect.center() - Vec2::new(0.0, 16.0),
|
rect.center() - Vec2::new(0.0, 16.0),
|
||||||
Align2::CENTER_CENTER,
|
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::Role::Body.font(),
|
||||||
theme::muted(),
|
theme::muted(),
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.center() + Vec2::new(0.0, 12.0),
|
rect.center() + Vec2::new(0.0, 12.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
opening.unwrap_or("opening…"),
|
name,
|
||||||
theme::Role::Title.font(),
|
theme::Role::Title.font(),
|
||||||
theme::text(),
|
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(
|
ui.scope_builder(
|
||||||
egui::UiBuilder::new()
|
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() {
|
if ui.button("Back").clicked() {
|
||||||
actions.push(Action::LeavePlayer);
|
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(),
|
theme::Role::Body.font(),
|
||||||
Color32::WHITE,
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
if response.on_hover_text("Play now").clicked() {
|
if response.hint("Play now").clicked() {
|
||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,11 +614,15 @@ fn up_next_card(
|
|||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
if ui
|
let (label, hint) = if card.tail {
|
||||||
.button("Watch till the end")
|
("Cancel", "Stay here — the next episode will not start")
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
} else {
|
||||||
.clicked()
|
(
|
||||||
{
|
"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);
|
actions.push(Action::WatchToEnd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -695,7 +747,7 @@ fn chrome(
|
|||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text(if fullscreen {
|
.hint(if fullscreen {
|
||||||
"Leave fullscreen (F)"
|
"Leave fullscreen (F)"
|
||||||
} else {
|
} else {
|
||||||
"Fullscreen (F)"
|
"Fullscreen (F)"
|
||||||
@@ -727,12 +779,31 @@ fn chrome(
|
|||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Episodes (E)")
|
.hint("Episodes (E)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleEpisodes);
|
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),
|
.frame(false),
|
||||||
)
|
)
|
||||||
.on_hover_text("Close (E)")
|
.hint("Close (E)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleEpisodes);
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
|
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||||
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||||
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
|
use pstr_player::LANGUAGES;
|
||||||
|
|
||||||
use crate::app::Action;
|
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::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// The settings that are a single value rather than a form.
|
/// 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
|
/// they are read-only, and passing them one by one is what pushed this
|
||||||
/// function past the argument count anybody can read.
|
/// function past the argument count anybody can read.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Prefs {
|
pub struct Prefs<'a> {
|
||||||
pub autoplay: bool,
|
pub autoplay: bool,
|
||||||
pub auto_skip: bool,
|
pub auto_skip: bool,
|
||||||
|
/// The languages, read from here.
|
||||||
|
pub playback: &'a PlaybackPrefs,
|
||||||
pub appearance: Appearance,
|
pub appearance: Appearance,
|
||||||
pub desktop: DesktopPrefs,
|
pub desktop: DesktopPrefs,
|
||||||
/// What the streaming cache holds, once the shares are open.
|
/// What the streaming cache holds, once the shares are open.
|
||||||
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
|
|||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
settings: &mut MetadataConfig,
|
settings: &mut MetadataConfig,
|
||||||
prefs: Prefs,
|
prefs: Prefs<'_>,
|
||||||
api_key: &mut String,
|
api_key: &mut String,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -51,7 +56,7 @@ pub fn show(
|
|||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.set_width(width);
|
ui.set_width(width);
|
||||||
ui.add_space(theme::space::M);
|
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);
|
playback_form(ui, prefs, actions);
|
||||||
storage_form(ui, prefs, actions);
|
storage_form(ui, prefs, actions);
|
||||||
metadata_form(ui, settings, api_key, 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 —
|
/// 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
|
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||||
/// only changed a small square would be asking someone to imagine the result.
|
/// only changed a small square would be asking someone to imagine the result.
|
||||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
fn appearance_form(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
appearance: Appearance,
|
||||||
|
desktop: DesktopPrefs,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||||
@@ -75,6 +85,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
|||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
for flavor in Flavor::ALL {
|
for flavor in Flavor::ALL {
|
||||||
if palette_card(ui, appearance, flavor)
|
if palette_card(ui, appearance, flavor)
|
||||||
|
.named(flavor.label())
|
||||||
.on_hover_text(flavor.description())
|
.on_hover_text(flavor.description())
|
||||||
.clicked()
|
.clicked()
|
||||||
&& appearance.flavor != flavor
|
&& 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::widgets::settings_row(
|
||||||
ui,
|
ui,
|
||||||
"Library tiles",
|
"Library tiles",
|
||||||
@@ -265,22 +297,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
.on_hover_text(accent.label())
|
.hint(accent.label())
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The playback settings that are not per-file.
|
/// The playback settings that are not per-file.
|
||||||
///
|
///
|
||||||
/// Volume, audio language, subtitle language and speed are all set from the
|
/// Volume and speed are set from the player itself, where a viewer can hear
|
||||||
/// player itself, where a viewer can hear the result.
|
/// the result. The languages are here as well as there: what a first episode
|
||||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
/// 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::widgets::settings_group(
|
||||||
ui,
|
ui,
|
||||||
"Playback",
|
"Playback",
|
||||||
Some(
|
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| {
|
|ui| {
|
||||||
|
languages(ui, prefs.playback, actions);
|
||||||
let mut autoplay = prefs.autoplay;
|
let mut autoplay = prefs.autoplay;
|
||||||
ui::widgets::settings_row(
|
ui::widgets::settings_row(
|
||||||
ui,
|
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.
|
/// 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::widgets::settings_group(
|
||||||
ui,
|
ui,
|
||||||
"Storage",
|
"Storage",
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
//! below is the only place a link is ever visible, and only while it is being
|
//! below is the only place a link is ever visible, and only while it is being
|
||||||
//! typed.
|
//! typed.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use pstr_core::Share;
|
use pstr_core::Share;
|
||||||
|
use pstr_core::catalog::CrawlRecord;
|
||||||
use pstr_core::library::Library;
|
use pstr_core::library::Library;
|
||||||
|
|
||||||
use crate::app::{Action, ShareForm};
|
use crate::app::{Action, ShareForm};
|
||||||
@@ -16,6 +19,7 @@ use crate::ui;
|
|||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
shares: &[Share],
|
shares: &[Share],
|
||||||
|
crawls: &HashMap<String, CrawlRecord>,
|
||||||
library: &Library,
|
library: &Library,
|
||||||
form: &mut ShareForm,
|
form: &mut ShareForm,
|
||||||
account: &mut ui::account::AccountPanel,
|
account: &mut ui::account::AccountPanel,
|
||||||
@@ -36,7 +40,7 @@ pub fn show(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|title| title.share_ids.contains(&share.id))
|
.filter(|title| title.share_ids.contains(&share.id))
|
||||||
.count();
|
.count();
|
||||||
share_row(ui, share, titles, actions);
|
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(theme::space::XXL);
|
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()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
.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 {
|
if share.has_custom_password {
|
||||||
detail.push_str(" · password protected");
|
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);
|
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| {
|
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() {
|
if ui.button("Crawl").clicked() {
|
||||||
actions.push(Action::Crawl(Some(share.id.clone())));
|
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);
|
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>) {
|
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Add a share");
|
ui::section(ui, "Add a share");
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
@@ -102,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
|
|
||||||
ui.label(ui::muted("Name"));
|
// Enter in any field of the form submits it, as the sign-in
|
||||||
ui.add(
|
// form's do.
|
||||||
egui::TextEdit::singleline(&mut form.name)
|
let mut entered = false;
|
||||||
.hint_text("Anime")
|
let mut submitted_from = |response: egui::Response| {
|
||||||
.desired_width(f32::INFINITY),
|
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
|
||||||
);
|
};
|
||||||
ui.add_space(theme::space::M);
|
|
||||||
|
|
||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let paste = 60.0;
|
let paste = 60.0;
|
||||||
let field = egui::Id::new("share-link");
|
let field = link_field_id();
|
||||||
ui.add(
|
if std::mem::take(&mut form.focus) {
|
||||||
egui::TextEdit::singleline(&mut form.url)
|
ui.memory_mut(|memory| memory.request_focus(field));
|
||||||
.id(field)
|
}
|
||||||
.password(true)
|
submitted_from(
|
||||||
.hint_text("https://drive.proton.me/urls/…#…")
|
ui.add(
|
||||||
.desired_width(ui.available_width() - paste),
|
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 —
|
// The link is masked, so pasting is the only sensible way in —
|
||||||
// and a button says that more plainly than the field does.
|
// 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.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");
|
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||||
if form.has_password {
|
if form.has_password {
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(
|
submitted_from(
|
||||||
egui::TextEdit::singleline(&mut form.password)
|
ui.add(
|
||||||
.password(true)
|
egui::TextEdit::singleline(&mut form.password)
|
||||||
.hint_text("Link password")
|
.password(true)
|
||||||
.desired_width(f32::INFINITY),
|
.hint_text("Link password")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
let ready = !form.name.trim().is_empty()
|
let ready = !form.url.trim().is_empty()
|
||||||
&& !form.url.trim().is_empty()
|
|
||||||
&& problem.is_none()
|
&& problem.is_none()
|
||||||
&& (!form.has_password || !form.password.is_empty())
|
&& (!form.has_password || !form.password.is_empty())
|
||||||
&& !form.sending;
|
&& !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.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
let clicked = ui
|
||||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
|
||||||
.inner
|
.inner
|
||||||
.clicked()
|
.clicked();
|
||||||
{
|
if clicked || (ready && entered) {
|
||||||
actions.push(Action::AddShare {
|
actions.push(Action::AddShare {
|
||||||
name: form.name.trim().to_string(),
|
name: form.name.trim().to_string(),
|
||||||
url: form.url.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 {
|
if form.sending {
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
ui.label(ui::muted("adding…"));
|
ui.label(ui::muted("adding…"));
|
||||||
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
} else if form.url.trim().is_empty() {
|
||||||
ui.label(ui::muted("A name and a link are needed."));
|
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::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||||
use crate::playback::PlaybackTarget;
|
use crate::playback::PlaybackTarget;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
use crate::ui::{self, Art, Card};
|
use crate::ui::{self, Art, Card};
|
||||||
|
|
||||||
pub struct OfflineView<'a> {
|
pub struct OfflineView<'a> {
|
||||||
@@ -96,6 +97,7 @@ pub fn show(
|
|||||||
)));
|
)));
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
season_download(ui, title, season, &offline, actions);
|
season_download(ui, title, season, &offline, actions);
|
||||||
|
season_watched(ui, title, season, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(theme::space::L);
|
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.
|
/// 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(
|
fn season_download(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
title: &Title,
|
title: &Title,
|
||||||
@@ -222,9 +247,10 @@ fn season_download(
|
|||||||
.on_hover_text("Delete the offline copies; keep the online source")
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
for key in season_keys {
|
actions.push(Action::DeleteOffline(
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
season_keys,
|
||||||
}
|
format!("{} {}", title.name, season.label()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
} else if ui
|
} else if ui
|
||||||
.button("Download season")
|
.button("Download season")
|
||||||
@@ -365,9 +391,7 @@ fn header(
|
|||||||
.on_hover_text("Delete the offline copies; keep the online source")
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
for key in title_keys {
|
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if ui
|
} else if ui
|
||||||
.button("Download show")
|
.button("Download show")
|
||||||
@@ -760,14 +784,33 @@ fn episode_row(
|
|||||||
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
if ui::widgets::watched_mark(ui, watched)
|
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
|
||||||
.on_hover_text(if watched {
|
"Mark unwatched. Right-click for more."
|
||||||
"Mark unwatched"
|
} else {
|
||||||
} else {
|
"Mark watched. Right-click for more."
|
||||||
"Mark watched"
|
});
|
||||||
})
|
// Joining a show partway is the usual reason to mark
|
||||||
.clicked()
|
// 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 {
|
actions.push(Action::SetWatched {
|
||||||
share_id: episode.node.share_id.clone(),
|
share_id: episode.node.share_id.clone(),
|
||||||
link_id: episode.node.link_id.clone(),
|
link_id: episode.node.link_id.clone(),
|
||||||
@@ -836,6 +879,7 @@ pub(crate) fn still(
|
|||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
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) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -941,7 +985,7 @@ fn download(
|
|||||||
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
actions.push(Action::DeleteOffline(vec![key], episode.label()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
|||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::Action;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// How long a message stays up.
|
/// How long a message stays up.
|
||||||
const SECONDS: f64 = 5.0;
|
const SECONDS: f64 = 5.0;
|
||||||
@@ -52,6 +53,17 @@ impl Toasts {
|
|||||||
self.push_inner(now, text.into(), false, Some((label, action)));
|
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(
|
fn push_inner(
|
||||||
&mut self,
|
&mut self,
|
||||||
now: f64,
|
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)],
|
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||||
Stroke::new(1.5, ink),
|
Stroke::new(1.5, ink),
|
||||||
);
|
);
|
||||||
if response.on_hover_text("Dismiss").clicked() {
|
if response.hint("Dismiss").clicked() {
|
||||||
pressed = Pressed::Close;
|
pressed = Pressed::Close;
|
||||||
}
|
}
|
||||||
if let Some((label, _)) = &toast.action {
|
if let Some((label, _)) = &toast.action {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use crate::app::{Action, Adjacent, Page};
|
|||||||
use crate::playback::{Command, Playback};
|
use crate::playback::{Command, Playback};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
/// 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.
|
/// 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,
|
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));
|
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
|
if response
|
||||||
.on_hover_text(if playback.paused {
|
.hint(if playback.paused {
|
||||||
"Play (Space)"
|
"Play (Space)"
|
||||||
} else {
|
} else {
|
||||||
"Pause (Space)"
|
"Pause (Space)"
|
||||||
@@ -354,7 +355,7 @@ fn step_button(
|
|||||||
enabled,
|
enabled,
|
||||||
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
||||||
);
|
);
|
||||||
if response.on_hover_text(hover).clicked() {
|
if response.hint(hover).clicked() {
|
||||||
push(actions);
|
push(actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +437,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
|||||||
};
|
};
|
||||||
if ui
|
if ui
|
||||||
.button(theme::Role::Subhead.rich(icon))
|
.button(theme::Role::Subhead.rich(icon))
|
||||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
.hint(format!("Mute (M) — {:.0}%", playback.volume))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleMute);
|
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))
|
format!("Speed · {}", ui::format_speed(playback.speed))
|
||||||
};
|
};
|
||||||
ui.menu_button(label, |ui| {
|
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
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
(playback.speed - speed).abs() < 1e-6,
|
(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 width = ui.available_width();
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||||
|
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();
|
let active = response.hovered() || response.dragged();
|
||||||
// Grows rather than jumps when the pointer arrives.
|
// Grows rather than jumps when the pointer arrives.
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
|||||||
|
|
||||||
use crate::theme;
|
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.
|
/// An on/off switch with its label beside it, for settings.
|
||||||
///
|
///
|
||||||
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
/// 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 {
|
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
||||||
const SIZE: f32 = 20.0;
|
const SIZE: f32 = 20.0;
|
||||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
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) {
|
if ui.is_rect_visible(rect) {
|
||||||
let ctx = ui.ctx();
|
let ctx = ui.ctx();
|
||||||
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
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 {
|
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
||||||
const SIZE: f32 = 30.0;
|
const SIZE: f32 = 30.0;
|
||||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
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) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use pstr_core::account::{PlaceKind, SignIn};
|
use pstr_core::account::{PlaceKind, SignIn};
|
||||||
use pstr_core::catalog::{Catalog, build_rows};
|
use pstr_core::catalog::{Catalog, build_rows, build_sidecars};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::shares::AccountFolder;
|
use pstr_core::shares::AccountFolder;
|
||||||
use pstr_core::sync::WatchSync;
|
use pstr_core::sync::WatchSync;
|
||||||
@@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let share = store
|
let (share, _) = store
|
||||||
.add(&name, &url, password.as_deref())
|
.add_verified(&name, &url, password.as_deref())
|
||||||
|
.await
|
||||||
.context("add share")?;
|
.context("add share")?;
|
||||||
println!("added {} ({})", share.name, share.id);
|
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 sync = WatchSync::open(&dirs).context("open sync state")?;
|
||||||
let catalog =
|
let catalog =
|
||||||
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open 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!(
|
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.devices,
|
||||||
report.applied,
|
report.applied,
|
||||||
|
profile.shares_added.len() + profile.shares_changed.len(),
|
||||||
|
profile.shares_removed.len(),
|
||||||
|
if profile.settings {
|
||||||
|
"updated"
|
||||||
|
} else {
|
||||||
|
"unchanged"
|
||||||
|
},
|
||||||
if report.uploaded {
|
if report.uploaded {
|
||||||
"uploaded this device's history"
|
"uploaded this device's history"
|
||||||
} else {
|
} else {
|
||||||
@@ -358,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
|
|
||||||
Command::Remove { id } => {
|
Command::Remove { id } => {
|
||||||
store.remove(&id).context("remove share")?;
|
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}");
|
println!("removed {id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
|
|
||||||
catalog
|
catalog
|
||||||
.replace_share(&share_id, &rows)
|
.replace_share(&share_id, &rows)
|
||||||
|
.and_then(|()| {
|
||||||
|
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
|
||||||
|
})
|
||||||
.with_context(|| format!("store {share_id}"))?;
|
.with_context(|| format!("store {share_id}"))?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
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};
|
use crate::metadata::{MetadataRecord, TitleNames};
|
||||||
|
|
||||||
/// What order a grid is in.
|
/// 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 {
|
pub enum Sort {
|
||||||
/// By the name shown, ignoring a leading article.
|
/// By the name shown, ignoring a leading article.
|
||||||
#[default]
|
#[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
|
/// The name to show for `title`: the provider's, in the form the viewer
|
||||||
/// picked, else the share's.
|
/// picked, else the share's.
|
||||||
pub fn display_name<'a>(
|
pub fn display_name<'a>(
|
||||||
@@ -392,4 +448,44 @@ mod tests {
|
|||||||
assert_eq!(found[0].key, "sousou no frieren");
|
assert_eq!(found[0].key, "sousou no frieren");
|
||||||
assert_eq!(search(&library, &metadata, "").len(), 3);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -46,9 +46,44 @@ pub enum Error {
|
|||||||
/// mailbox password.
|
/// mailbox password.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
SignIn(String),
|
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 {
|
impl Error {
|
||||||
|
/// The message, followed by every cause its own text leaves out.
|
||||||
|
///
|
||||||
|
/// A dropped connection reads as reqwest's "error sending request for url
|
||||||
|
/// (…)" and nothing else: why — a reset, a DNS failure, a certificate — is
|
||||||
|
/// further down the source chain. That is the part a viewer reporting a
|
||||||
|
/// failed crawl needs, so the URL, which only says which folder, is left
|
||||||
|
/// out to keep it on screen.
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let mut text = match self {
|
||||||
|
Self::Drive(proton_sdk::error::ProtonError::Transport(error)) => {
|
||||||
|
let url = error.url().map(|url| format!(" for url ({url})"));
|
||||||
|
let own = self.to_string();
|
||||||
|
url.map_or_else(|| own.clone(), |url| own.replace(&url, ""))
|
||||||
|
}
|
||||||
|
_ => self.to_string(),
|
||||||
|
};
|
||||||
|
let mut seen = self.to_string();
|
||||||
|
let mut source = std::error::Error::source(self);
|
||||||
|
while let Some(cause) = source {
|
||||||
|
let line = cause.to_string();
|
||||||
|
if !seen.contains(&line) {
|
||||||
|
text.push_str(": ");
|
||||||
|
text.push_str(&line);
|
||||||
|
seen.push_str(&line);
|
||||||
|
}
|
||||||
|
source = cause.source();
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this is the account's session having ended: the refresh token
|
/// Whether this is the account's session having ended: the refresh token
|
||||||
/// was revoked or expired, or the token lacks a scope only a password
|
/// was revoked or expired, or the token lacks a scope only a password
|
||||||
/// sign-in grants. Nothing but signing in again fixes it.
|
/// sign-in grants. Nothing but signing in again fixes it.
|
||||||
@@ -71,3 +106,25 @@ impl From<keyring::Error> for Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("connection closed before message completed")]
|
||||||
|
struct Closed;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("client error (SendRequest)")]
|
||||||
|
struct SendRequest(#[source] Closed);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_described_with_the_causes_its_own_text_leaves_out() {
|
||||||
|
let error = Error::Io(std::io::Error::other(SendRequest(Closed)));
|
||||||
|
assert_eq!(
|
||||||
|
error.describe(),
|
||||||
|
"io: client error (SendRequest): connection closed before message completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Language tags: what a container or a file name calls a language, and what a
|
||||||
|
//! viewer calls it.
|
||||||
|
|
||||||
|
/// 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",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Each language's name and the tags it goes by, the [`LANGUAGES`] form first.
|
||||||
|
///
|
||||||
|
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
||||||
|
/// the shares this app is pointed at actually contain, and an unrecognised tag
|
||||||
|
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
|
||||||
|
/// on its own.
|
||||||
|
const TABLE: [(&str, &[&str]); 29] = [
|
||||||
|
("English", &["eng", "en"]),
|
||||||
|
("Japanese", &["jpn", "ja"]),
|
||||||
|
("German", &["ger", "de", "deu"]),
|
||||||
|
("French", &["fre", "fr", "fra"]),
|
||||||
|
("Spanish", &["spa", "es"]),
|
||||||
|
("Italian", &["ita", "it"]),
|
||||||
|
("Portuguese", &["por", "pt"]),
|
||||||
|
("Dutch", &["dut", "nl", "nld"]),
|
||||||
|
("Russian", &["rus", "ru"]),
|
||||||
|
("Polish", &["pol", "pl"]),
|
||||||
|
("Swedish", &["swe", "sv"]),
|
||||||
|
("Danish", &["dan", "da"]),
|
||||||
|
("Norwegian", &["nor", "no"]),
|
||||||
|
("Finnish", &["fin", "fi"]),
|
||||||
|
("Czech", &["cze", "cs", "ces"]),
|
||||||
|
("Hungarian", &["hun", "hu"]),
|
||||||
|
("Turkish", &["tur", "tr"]),
|
||||||
|
("Arabic", &["ara", "ar"]),
|
||||||
|
("Hebrew", &["heb", "he"]),
|
||||||
|
("Hindi", &["hin", "hi"]),
|
||||||
|
("Korean", &["kor", "ko"]),
|
||||||
|
("Chinese", &["chi", "zh", "zho"]),
|
||||||
|
("Thai", &["tha", "th"]),
|
||||||
|
("Vietnamese", &["vie", "vi"]),
|
||||||
|
("Ukrainian", &["ukr", "uk"]),
|
||||||
|
("Romanian", &["rum", "ro", "ron"]),
|
||||||
|
("Greek", &["gre", "el", "ell"]),
|
||||||
|
("Indonesian", &["ind", "id"]),
|
||||||
|
("Malay", &["may", "ms", "msa"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A readable name for a language tag, falling back to the tag itself.
|
||||||
|
pub fn language_name(tag: &str) -> String {
|
||||||
|
// Container tags come in both the two- and three-letter forms, and often
|
||||||
|
// with a region suffix: "en", "eng", "en-US", "pt-BR".
|
||||||
|
let base = tag.split(['-', '_']).next().unwrap_or(tag);
|
||||||
|
let Some((name, _)) = TABLE
|
||||||
|
.iter()
|
||||||
|
.find(|(_, tags)| tags.iter().any(|known| known.eq_ignore_ascii_case(base)))
|
||||||
|
else {
|
||||||
|
return tag.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The region matters when there is one — Brazilian Portuguese is not
|
||||||
|
// Portuguese to anyone choosing between the two.
|
||||||
|
match tag.split_once(['-', '_']) {
|
||||||
|
Some((_, region)) if !region.is_empty() => {
|
||||||
|
format!("{name} ({})", region.to_ascii_uppercase())
|
||||||
|
}
|
||||||
|
_ => (*name).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||||
|
pub fn same_language(a: &str, b: &str) -> bool {
|
||||||
|
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`LANGUAGES`] tag for one word of a file name, if it names a language:
|
||||||
|
/// "en", "eng" and "English" are all `"eng"`.
|
||||||
|
///
|
||||||
|
/// For the words around a subtitle file's name — `Episode 01.en.srt`,
|
||||||
|
/// `2_English.srt` — where a two-letter word is far more often a language than
|
||||||
|
/// anything else.
|
||||||
|
pub fn language_tag(word: &str) -> Option<&'static str> {
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.find(|(name, tags)| {
|
||||||
|
name.eq_ignore_ascii_case(word) || tags.iter().any(|tag| tag.eq_ignore_ascii_case(word))
|
||||||
|
})
|
||||||
|
.map(|(_, tags)| tags[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
|
||||||
|
assert_eq!(language_name("jpn"), "Japanese");
|
||||||
|
assert_eq!(language_name("ja"), "Japanese");
|
||||||
|
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
|
||||||
|
// An unknown tag is shown as it stands rather than as "Unknown", which
|
||||||
|
// would make two of them indistinguishable.
|
||||||
|
assert_eq!(language_name("mis"), "mis");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_language_named_by_either_tag_or_by_its_name_has_the_tag_a_preference_uses() {
|
||||||
|
assert_eq!(language_tag("en"), Some("eng"));
|
||||||
|
assert_eq!(language_tag("ENG"), Some("eng"));
|
||||||
|
assert_eq!(language_tag("German"), Some("ger"));
|
||||||
|
assert_eq!(language_tag("deu"), Some("ger"));
|
||||||
|
assert_eq!(language_tag("forced"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_language_offered_by_name_is_one_the_table_knows() {
|
||||||
|
let firsts: Vec<&str> = TABLE.iter().map(|(_, tags)| tags[0]).collect();
|
||||||
|
let mut offered = LANGUAGES.to_vec();
|
||||||
|
let mut known = firsts.clone();
|
||||||
|
offered.sort_unstable();
|
||||||
|
known.sort_unstable();
|
||||||
|
assert_eq!(offered, known);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,15 @@ pub mod chapters;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod franchise;
|
pub mod franchise;
|
||||||
|
pub mod language;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod naming;
|
pub mod naming;
|
||||||
pub mod prefs;
|
pub mod prefs;
|
||||||
|
pub mod profile;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
pub mod shelves;
|
pub mod shelves;
|
||||||
|
pub mod subtitles;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use account::{Account, AccountStore};
|
pub use account::{Account, AccountStore};
|
||||||
|
|||||||
@@ -189,10 +189,44 @@ impl Title {
|
|||||||
.max_by_key(|episode| episode.last_played())
|
.max_by_key(|episode| episode.last_played())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a click on the poster should play: whatever is part-watched, else
|
/// What Continue watching offers for this title: the part-watched episode,
|
||||||
/// the first unwatched episode, else the first 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> {
|
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().find(|episode| !episode.is_watched()))
|
||||||
.or_else(|| self.episodes().next())
|
.or_else(|| self.episodes().next())
|
||||||
}
|
}
|
||||||
@@ -357,13 +391,13 @@ impl Library {
|
|||||||
self.titles.iter().find(|title| title.key == key)
|
self.titles.iter().find(|title| title.key == key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Titles with something part-watched, most recent first. The top row of
|
/// Titles with something to go on with — see [`Title::continue_with`] —
|
||||||
/// the library.
|
/// most recent first. The top row of the library.
|
||||||
pub fn continue_watching(&self) -> Vec<&Title> {
|
pub fn continue_watching(&self) -> Vec<&Title> {
|
||||||
let mut titles: Vec<&Title> = self
|
let mut titles: Vec<&Title> = self
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|title| title.resume().is_some())
|
.filter(|title| title.continue_with().is_some())
|
||||||
.collect();
|
.collect();
|
||||||
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
|
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
|
||||||
titles
|
titles
|
||||||
@@ -688,6 +722,64 @@ mod tests {
|
|||||||
assert_eq!(library.continue_watching().len(), 1);
|
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]
|
#[test]
|
||||||
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
|
|||||||
@@ -813,6 +813,15 @@ pub fn is_video_file(name: &str) -> bool {
|
|||||||
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subtitle formats mpv reads from a file of their own.
|
||||||
|
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "ssa", "vtt"];
|
||||||
|
|
||||||
|
/// Whether a filename names a subtitle file a video can be paired with.
|
||||||
|
pub fn is_subtitle_file(name: &str) -> bool {
|
||||||
|
name.rsplit_once('.')
|
||||||
|
.is_some_and(|(_, ext)| SUBTITLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,11 +27,13 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
||||||
};
|
};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -355,19 +357,19 @@ impl AccountFolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The secrets for one share, as held in the credential store.
|
/// The secrets for one share, as held in the credential store.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct ShareSecrets {
|
pub(crate) struct ShareSecrets {
|
||||||
/// The full share URL, fragment included.
|
/// The full share URL, fragment included.
|
||||||
url: String,
|
pub(crate) url: String,
|
||||||
/// The custom password, when the link has one.
|
/// The custom password, when the link has one.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
custom_password: Option<String>,
|
pub(crate) custom_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The configured shares, and their secrets.
|
/// The configured shares, and their secrets.
|
||||||
pub struct ShareStore {
|
pub struct ShareStore {
|
||||||
dirs: AppDirs,
|
pub(crate) dirs: AppDirs,
|
||||||
secrets: Arc<dyn SecretStore>,
|
pub(crate) secrets: Arc<dyn SecretStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShareStore {
|
impl ShareStore {
|
||||||
@@ -396,9 +398,9 @@ impl ShareStore {
|
|||||||
|
|
||||||
let mut shares = self.list()?;
|
let mut shares = self.list()?;
|
||||||
if shares.iter().any(|share| share.id == id) {
|
if shares.iter().any(|share| share.id == id) {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(
|
||||||
"a share for token {token} is already configured"
|
"that link is already in the library".to_owned(),
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let secrets = ShareSecrets {
|
let secrets = ShareSecrets {
|
||||||
@@ -421,6 +423,80 @@ impl ShareStore {
|
|||||||
Ok(share)
|
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.
|
/// Record a folder of the signed-in account as a share.
|
||||||
///
|
///
|
||||||
/// Nothing goes in the credential store: the folder opens through the
|
/// Nothing goes in the credential store: the folder opens through the
|
||||||
@@ -493,6 +569,25 @@ impl ShareStore {
|
|||||||
Ok(updated)
|
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.
|
/// Forget a share and its secrets.
|
||||||
///
|
///
|
||||||
/// The config entry goes first here, for the mirror-image reason: if the
|
/// The config entry goes first here, for the mirror-image reason: if the
|
||||||
@@ -526,17 +621,7 @@ impl ShareStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let secrets = self.load_secrets(&share.id)?;
|
let secrets = self.load_secrets(&share.id)?;
|
||||||
let client = ProtonDrivePublicLinkClient::open(
|
open_link(&secrets.url, secrets.custom_password.as_deref()).await
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||||
@@ -556,9 +641,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.
|
/// Several opened shares, presented as one library.
|
||||||
pub struct SharedLibrary {
|
pub struct SharedLibrary {
|
||||||
clients: BTreeMap<String, ShareClient>,
|
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.
|
/// One opened share, whichever kind it is.
|
||||||
@@ -680,6 +845,9 @@ impl SharedLibrary {
|
|||||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||||
let mut clients = BTreeMap::new();
|
let mut clients = BTreeMap::new();
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
|
// 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()? {
|
for share in store.list()? {
|
||||||
if let Some(client) = reusable.remove(&share.id) {
|
if let Some(client) = reusable.remove(&share.id) {
|
||||||
@@ -688,13 +856,14 @@ impl SharedLibrary {
|
|||||||
}
|
}
|
||||||
match store.open(&share, account).await {
|
match store.open(&share, account).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
|
sessions.mark(&share.id);
|
||||||
clients.insert(share.id.clone(), client);
|
clients.insert(share.id.clone(), client);
|
||||||
}
|
}
|
||||||
Err(e) => failures.push((share, e)),
|
Err(e) => failures.push((share, e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((Self { clients }, failures))
|
Ok((Self { clients, sessions }, failures))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The opened clients, minus `dropping`, ready to hand to
|
/// The opened clients, minus `dropping`, ready to hand to
|
||||||
@@ -735,9 +904,37 @@ impl SharedLibrary {
|
|||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
client.refresh_session().await?;
|
client.refresh_session().await?;
|
||||||
|
self.sessions.mark(share_id);
|
||||||
Ok(())
|
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.
|
/// Walk one share's whole subtree, level by level, yielding every node.
|
||||||
///
|
///
|
||||||
/// There is no recursion helper on the visitor path — the authenticated
|
/// There is no recursion helper on the visitor path — the authenticated
|
||||||
@@ -778,7 +975,10 @@ impl SharedLibrary {
|
|||||||
.client(share_id)
|
.client(share_id)
|
||||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||||
|
|
||||||
let root = client.root_node().await?;
|
let root = retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
|
||||||
|
client.root_node()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let mut level = vec![root.uid.clone()];
|
let mut level = vec![root.uid.clone()];
|
||||||
let mut found = vec![root];
|
let mut found = vec![root];
|
||||||
|
|
||||||
@@ -786,14 +986,10 @@ impl SharedLibrary {
|
|||||||
let listings = level.into_iter().map(|folder| {
|
let listings = level.into_iter().map(|folder| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
async move {
|
async move {
|
||||||
let child_uids: Vec<NodeUid> =
|
retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
|
||||||
client.enumerate_folder_children_node_uids(&folder).await?;
|
list_folder(&client, &folder)
|
||||||
if child_uids.is_empty() {
|
})
|
||||||
return Ok(Vec::new());
|
.await
|
||||||
}
|
|
||||||
// Chunked and fanned out inside the SDK too, so this is a
|
|
||||||
// multiplier on that concurrency rather than the only one.
|
|
||||||
client.enumerate_nodes(&child_uids).await
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -815,6 +1011,59 @@ impl SharedLibrary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One folder's children, with their details.
|
||||||
|
async fn list_folder(
|
||||||
|
client: &ShareClient,
|
||||||
|
folder: &NodeUid,
|
||||||
|
) -> proton_sdk::error::Result<Vec<Node>> {
|
||||||
|
let child_uids: Vec<NodeUid> = client.enumerate_folder_children_node_uids(folder).await?;
|
||||||
|
if child_uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
// Chunked and fanned out inside the SDK too, so this is a multiplier on
|
||||||
|
// that concurrency rather than the only one.
|
||||||
|
client.enumerate_nodes(&child_uids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `operation`, and again after each delay while it fails in a way
|
||||||
|
/// `retriable` says another attempt could fix.
|
||||||
|
///
|
||||||
|
/// A crawl is hundreds of listings, and one that fails ends the crawl. The SDK
|
||||||
|
/// replays a timeout or a refused connection itself, but not a connection that
|
||||||
|
/// dropped mid-request — the request may have been applied, which matters for
|
||||||
|
/// a write and not at all for a listing. A phone sees exactly that: a pooled
|
||||||
|
/// connection the OS tore down while the app was off screen, or a hand-over
|
||||||
|
/// from Wi-Fi to mobile data.
|
||||||
|
async fn retrying<T, E, O, OF>(
|
||||||
|
delays: &[Duration],
|
||||||
|
retriable: impl Fn(&E) -> bool,
|
||||||
|
operation: O,
|
||||||
|
) -> std::result::Result<T, E>
|
||||||
|
where
|
||||||
|
O: Fn() -> OF,
|
||||||
|
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||||
|
{
|
||||||
|
let mut delays = delays.iter();
|
||||||
|
loop {
|
||||||
|
match operation().await {
|
||||||
|
Err(error) if retriable(&error) => match delays.next() {
|
||||||
|
Some(delay) => tokio::time::sleep(*delay).await,
|
||||||
|
None => return Err(error),
|
||||||
|
},
|
||||||
|
done => return done,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a crawl waits before listing a folder again. About a quarter of a
|
||||||
|
/// minute in all: long enough for a network to come back, short enough that a
|
||||||
|
/// share that is really gone still says so.
|
||||||
|
const CRAWL_RETRY_DELAYS: [Duration; 3] = [
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(3),
|
||||||
|
Duration::from_secs(9),
|
||||||
|
];
|
||||||
|
|
||||||
/// How many folders of one level to list at once.
|
/// How many folders of one level to list at once.
|
||||||
///
|
///
|
||||||
/// Each of these fans out again inside the SDK — up to four detail batches per
|
/// Each of these fans out again inside the SDK — up to four detail batches per
|
||||||
@@ -879,6 +1128,107 @@ fn token_from_url(url: &str) -> Result<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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_listing_that_drops_is_tried_again_until_it_comes_back() {
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(2, &calls)).await;
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listing_that_keeps_failing_reports_its_failure_once_the_delays_run_out() {
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = retrying(&[Duration::ZERO; 3], |_| true, flaky(9, &calls)).await;
|
||||||
|
assert_eq!(result, Err(()));
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failure_another_attempt_cannot_fix_is_reported_at_once() {
|
||||||
|
let calls = AtomicUsize::new(0);
|
||||||
|
let result = retrying(&[Duration::ZERO; 3], |_| false, flaky(1, &calls)).await;
|
||||||
|
assert_eq!(result, Err(()));
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn a_share_url_yields_its_token() {
|
fn a_share_url_yields_its_token() {
|
||||||
@@ -1004,6 +1354,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
|
/// 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
|
/// leave every catalog row and offline file describing files that are not
|
||||||
/// there.
|
/// there.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! Which of a file's subtitle tracks is the one to read.
|
||||||
|
//!
|
||||||
|
//! Anime releases often carry two subtitle tracks in one language: the
|
||||||
|
//! dialogue, and a "Signs & Songs" track that only translates the on-screen
|
||||||
|
//! text and the lyrics. The second is the one marked default or forced as
|
||||||
|
//! often as not, and mpv, picking by language, plays it — an episode where
|
||||||
|
//! nobody seems to speak. A language cannot tell the two apart; the track's
|
||||||
|
//! name and its forced flag can.
|
||||||
|
//!
|
||||||
|
//! Nothing here talks to a player. Every client reads the track list out of
|
||||||
|
//! mpv once a file is loaded and asks [`full_instead`] whether to switch.
|
||||||
|
|
||||||
|
use crate::language::same_language;
|
||||||
|
|
||||||
|
/// One subtitle track of the open file, as mpv lists it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct SubtitleTrack {
|
||||||
|
/// mpv's id for it, what `sid` is set to.
|
||||||
|
pub id: i64,
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// The name the muxer gave it: "Dialogue", "Signs & Songs".
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// The container marks it as the default choice.
|
||||||
|
pub default: bool,
|
||||||
|
/// The container marks it as forced: on-screen text only, by convention.
|
||||||
|
pub forced: bool,
|
||||||
|
/// It is the one playing.
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of what is said a track carries, as far as its name and flags say.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum Coverage {
|
||||||
|
/// Signs, songs, or forced: not the dialogue.
|
||||||
|
Partial,
|
||||||
|
/// Nothing says either way.
|
||||||
|
Unknown,
|
||||||
|
/// Named as the dialogue: "Dialogue", "Full Subtitles", "SDH".
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words in a track's name that make it the whole dialogue. They win over the
|
||||||
|
/// partial ones: "Full (with Signs)" is still the full track.
|
||||||
|
const FULL_WORDS: [&str; 6] = ["full", "dialogue", "dialog", "dialogues", "sdh", "cc"];
|
||||||
|
|
||||||
|
/// Words in a track's name that make it something other than the dialogue.
|
||||||
|
const PARTIAL_WORDS: [&str; 13] = [
|
||||||
|
"sign", "signs", "song", "songs", "s&s", "forced", "karaoke", "lyrics", "op", "ed", "oped",
|
||||||
|
"opening", "ending",
|
||||||
|
];
|
||||||
|
|
||||||
|
impl SubtitleTrack {
|
||||||
|
pub fn coverage(&self) -> Coverage {
|
||||||
|
let title = self.title.as_deref().unwrap_or_default().to_lowercase();
|
||||||
|
let words: Vec<&str> = title
|
||||||
|
.split(|c: char| !c.is_alphanumeric() && c != '&')
|
||||||
|
.filter(|word| !word.is_empty())
|
||||||
|
.collect();
|
||||||
|
let has = |list: &[&str]| words.iter().any(|word| list.contains(word));
|
||||||
|
if has(&FULL_WORDS) {
|
||||||
|
Coverage::Full
|
||||||
|
} else if self.forced || has(&PARTIAL_WORDS) {
|
||||||
|
Coverage::Partial
|
||||||
|
} else {
|
||||||
|
Coverage::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The track to play in place of the one mpv chose, if it chose the wrong one.
|
||||||
|
///
|
||||||
|
/// Only a track in the same language as the playing one is considered — the
|
||||||
|
/// language is the viewer's choice, and this only corrects which of its tracks
|
||||||
|
/// was taken. The playing track is replaced when it is partial and the file has
|
||||||
|
/// one in its language that is not, or when nothing names it and another is
|
||||||
|
/// named as the dialogue. Among the rest, a track named as the dialogue comes
|
||||||
|
/// first, then the container's default, then the file's order. `None` leaves
|
||||||
|
/// the selection as it is, which includes subtitles being off.
|
||||||
|
pub fn full_instead(tracks: &[SubtitleTrack]) -> Option<i64> {
|
||||||
|
let playing = tracks.iter().find(|track| track.selected)?;
|
||||||
|
let current = playing.coverage();
|
||||||
|
if current == Coverage::Full {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let same = |track: &SubtitleTrack| match (&playing.language, &track.language) {
|
||||||
|
(Some(a), Some(b)) => same_language(a, b),
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|track| track.id != playing.id && same(track))
|
||||||
|
.filter(|track| track.coverage() > current)
|
||||||
|
// `max_by_key` keeps the last of equals; reversed, the first.
|
||||||
|
.rev()
|
||||||
|
.max_by_key(|track| (track.coverage(), track.default))
|
||||||
|
.map(|track| track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn track(id: i64, language: &str, title: &str) -> SubtitleTrack {
|
||||||
|
SubtitleTrack {
|
||||||
|
id,
|
||||||
|
language: Some(language.into()),
|
||||||
|
title: (!title.is_empty()).then(|| title.into()),
|
||||||
|
..SubtitleTrack::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playing(mut track: SubtitleTrack) -> SubtitleTrack {
|
||||||
|
track.selected = true;
|
||||||
|
track
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signs_and_songs_playing_gives_way_to_the_dialogue_in_its_language() {
|
||||||
|
let tracks = [
|
||||||
|
playing(track(1, "eng", "Signs & Songs")),
|
||||||
|
track(2, "eng", "Dialogue"),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unnamed_track_gives_way_to_one_named_as_the_dialogue() {
|
||||||
|
let tracks = [playing(track(1, "en", "")), track(2, "en", "Dialogue")];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_forced_track_gives_way_to_an_unnamed_one_and_the_default_comes_first() {
|
||||||
|
let mut forced = playing(track(1, "eng", "English"));
|
||||||
|
forced.forced = true;
|
||||||
|
let mut default = track(3, "eng", "");
|
||||||
|
default.default = true;
|
||||||
|
let tracks = [forced, track(2, "eng", ""), default];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_candidates_resolve_to_the_first_in_the_file() {
|
||||||
|
let tracks = [
|
||||||
|
playing(track(1, "eng", "Songs")),
|
||||||
|
track(2, "eng", ""),
|
||||||
|
track(3, "eng", ""),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_track_in_another_language_is_never_switched_to() {
|
||||||
|
let tracks = [
|
||||||
|
playing(track(1, "eng", "Signs")),
|
||||||
|
track(2, "ger", "Dialogue"),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_letter_and_three_letter_tags_are_one_language() {
|
||||||
|
let tracks = [playing(track(1, "en", "OP/ED")), track(2, "eng", "")];
|
||||||
|
assert_eq!(full_instead(&tracks), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_dialogue_playing_stays_and_subtitles_off_stay_off() {
|
||||||
|
let tracks = [
|
||||||
|
track(1, "eng", "Signs & Songs"),
|
||||||
|
playing(track(2, "eng", "Dialogue")),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&tracks), None);
|
||||||
|
let off = [
|
||||||
|
track(1, "eng", "Signs & Songs"),
|
||||||
|
track(2, "eng", "Dialogue"),
|
||||||
|
];
|
||||||
|
assert_eq!(full_instead(&off), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_word_outweighs_a_partial_one_in_the_same_name() {
|
||||||
|
assert_eq!(
|
||||||
|
track(1, "eng", "Full (with Signs)").coverage(),
|
||||||
|
Coverage::Full
|
||||||
|
);
|
||||||
|
assert_eq!(track(1, "eng", "Signs/Songs").coverage(), Coverage::Partial);
|
||||||
|
assert_eq!(track(1, "eng", "English").coverage(), Coverage::Unknown);
|
||||||
|
// A word inside another is not that word.
|
||||||
|
assert_eq!(
|
||||||
|
track(1, "eng", "Designer cut").coverage(),
|
||||||
|
Coverage::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_partial_track_with_only_partial_company_stays() {
|
||||||
|
let tracks = [playing(track(1, "eng", "Signs")), track(2, "eng", "Songs")];
|
||||||
|
assert_eq!(full_instead(&tracks), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Watch history kept in the viewer's own Drive, so every device resumes where
|
//! 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
|
//! ## Layout
|
||||||
//!
|
//!
|
||||||
@@ -17,7 +19,8 @@
|
|||||||
//! nothing is overwritten and there is no write conflict to resolve.
|
//! nothing is overwritten and there is no write conflict to resolve.
|
||||||
//!
|
//!
|
||||||
//! Each file carries the installation's whole watch table, and merging is per
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! `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
|
//! the same `(share_id, link_id)` on both. The watch entries carry nothing
|
||||||
//! link token decrypts nothing without the fragment — and the file is
|
//! secret; the profile's share registers do carry each link and its password,
|
||||||
//! end-to-end encrypted by Drive regardless.
|
//! 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 parking_lot::Mutex;
|
||||||
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
|
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
|
||||||
@@ -40,6 +43,8 @@ use crate::account::Account;
|
|||||||
use crate::catalog::{Catalog, WatchState};
|
use crate::catalog::{Catalog, WatchState};
|
||||||
use crate::config::{AppDirs, read_json, write_json};
|
use crate::config::{AppDirs, read_json, write_json};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::profile::{Profile, ProfileChanges, Registers};
|
||||||
|
use crate::shares::ShareStore;
|
||||||
|
|
||||||
/// The Drive device watch history is kept on.
|
/// The Drive device watch history is kept on.
|
||||||
pub const DEVICE_NAME: &str = "proton-stream";
|
pub const DEVICE_NAME: &str = "proton-stream";
|
||||||
@@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream";
|
|||||||
const FOLDER: &str = "watch-history";
|
const FOLDER: &str = "watch-history";
|
||||||
|
|
||||||
/// Bumped only for a change an older build would misread. A file of a newer
|
/// 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;
|
const FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
/// One installation's watch history, as stored in Drive.
|
/// One installation's watch history, as stored in Drive.
|
||||||
@@ -57,6 +63,15 @@ struct HistoryFile {
|
|||||||
version: u32,
|
version: u32,
|
||||||
installation: String,
|
installation: String,
|
||||||
entries: Vec<HistoryEntry>,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -112,7 +127,7 @@ struct Identity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What one [`WatchSync::sync`] did.
|
/// What one [`WatchSync::sync`] did.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct SyncReport {
|
pub struct SyncReport {
|
||||||
/// Episodes whose position another device had newer.
|
/// Episodes whose position another device had newer.
|
||||||
pub applied: usize,
|
pub applied: usize,
|
||||||
@@ -123,6 +138,8 @@ pub struct SyncReport {
|
|||||||
/// Files of installations gone quiet that were moved to the trash, their
|
/// Files of installations gone quiet that were moved to the trash, their
|
||||||
/// history now carried in this installation's own file.
|
/// history now carried in this installation's own file.
|
||||||
pub retired: usize,
|
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
|
/// 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`
|
/// 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 {
|
struct Seen {
|
||||||
revision: Option<String>,
|
revision: Option<String>,
|
||||||
entries: Option<Vec<HistoryEntry>>,
|
contents: Option<Contents>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Syncs the catalog's watch table with the account's Drive.
|
/// Syncs the catalog's watch table with the account's Drive.
|
||||||
@@ -257,8 +274,9 @@ pub struct WatchSync {
|
|||||||
seen: Mutex<HashMap<String, Seen>>,
|
seen: Mutex<HashMap<String, Seen>>,
|
||||||
/// What this installation's own file holds, once known: what was read
|
/// What this installation's own file holds, once known: what was read
|
||||||
/// back at the first sync, then what was last written. Compared against
|
/// 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.
|
/// the catalog and the profile so an unchanged history is not uploaded as
|
||||||
own: Mutex<Option<Vec<HistoryEntry>>>,
|
/// a new revision.
|
||||||
|
own: Mutex<Option<Contents>>,
|
||||||
/// The history folders, resolved once per account. Usually one; see
|
/// The history folders, resolved once per account. Usually one; see
|
||||||
/// [`HistoryDrive::history_folders`].
|
/// [`HistoryDrive::history_folders`].
|
||||||
folders: Mutex<Option<Vec<NodeUid>>>,
|
folders: Mutex<Option<Vec<NodeUid>>>,
|
||||||
@@ -303,25 +321,34 @@ impl WatchSync {
|
|||||||
*self.folders.lock() = None;
|
*self.folders.lock() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull every other installation's history into `catalog`, write this
|
/// Pull every other installation's history, shares and settings in, write
|
||||||
/// one's if it changed, then retire the files of installations gone quiet.
|
/// 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
|
/// The catalog is locked only around its own reads and writes, never
|
||||||
/// across a request, so playback can keep saving its position while this
|
/// across a request, so playback can keep saving its position while this
|
||||||
/// runs.
|
/// runs. Shares that arrive are configured but not opened or crawled; see
|
||||||
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
|
/// [`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()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map_or(0, |since| since.as_secs() as i64);
|
.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>(
|
pub(crate) async fn sync_with<D: HistoryDrive>(
|
||||||
&self,
|
&self,
|
||||||
drive: &D,
|
drive: &D,
|
||||||
catalog: &Mutex<Catalog>,
|
profile: &Profile<'_>,
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<SyncReport> {
|
) -> Result<SyncReport> {
|
||||||
|
let catalog = profile.catalog;
|
||||||
let _running = self.running.lock().await;
|
let _running = self.running.lock().await;
|
||||||
let folders = self.folders(drive).await?;
|
let folders = self.folders(drive).await?;
|
||||||
let own_name = format!("{}.json", self.installation);
|
let own_name = format!("{}.json", self.installation);
|
||||||
@@ -339,12 +366,14 @@ impl WatchSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut remote = Vec::new();
|
let mut remote = Vec::new();
|
||||||
|
let mut registers = Vec::new();
|
||||||
// Files whose every entry has been read, and so can be retired once
|
// Files whose every entry has been read, and so can be retired once
|
||||||
// this installation's file carries them.
|
// this installation's file carries them.
|
||||||
let mut readable = Vec::new();
|
let mut readable = Vec::new();
|
||||||
for file in &others {
|
for file in &others {
|
||||||
if let Some(entries) = self.read(drive, file).await {
|
if let Some(contents) = self.read(drive, file).await {
|
||||||
remote.extend(entries);
|
remote.extend(contents.entries);
|
||||||
|
registers.push(contents.registers);
|
||||||
readable.push(file);
|
readable.push(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,19 +384,22 @@ impl WatchSync {
|
|||||||
// less than it holds, and read again next time.
|
// less than it holds, and read again next time.
|
||||||
let mut own_unreadable = false;
|
let mut own_unreadable = false;
|
||||||
if self.own.lock().is_none() {
|
if self.own.lock().is_none() {
|
||||||
let entries = match &own_file {
|
let contents = match &own_file {
|
||||||
Some(file) => self.read(drive, file).await,
|
Some(file) => self.read(drive, file).await,
|
||||||
None => Some(Vec::new()),
|
None => Some(Contents::default()),
|
||||||
};
|
};
|
||||||
match entries {
|
match contents {
|
||||||
Some(entries) => {
|
Some(contents) => {
|
||||||
remote.extend(entries.iter().cloned());
|
remote.extend(contents.entries.iter().cloned());
|
||||||
*self.own.lock() = Some(entries);
|
registers.push(contents.registers.clone());
|
||||||
|
*self.own.lock() = Some(contents);
|
||||||
}
|
}
|
||||||
None => own_unreadable = true,
|
None => own_unreadable = true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (registers, profile_changes) = profile.sync(®isters, now)?;
|
||||||
|
|
||||||
let incoming: Vec<(String, String, WatchState)> = remote
|
let incoming: Vec<(String, String, WatchState)> = remote
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| entry.is_sane())
|
.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
|
// 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
|
// not changed: another installation retired it, or it was deleted by
|
||||||
// hand, and either way the history it carried lives only here now.
|
// 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 contents = Contents { entries, registers };
|
||||||
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
|
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
|
false
|
||||||
} else {
|
} else {
|
||||||
let file = HistoryFile {
|
let file = HistoryFile {
|
||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
installation: self.installation.clone(),
|
installation: self.installation.clone(),
|
||||||
entries: entries.clone(),
|
entries: contents.entries.clone(),
|
||||||
|
registers: contents.registers.clone(),
|
||||||
};
|
};
|
||||||
let bytes = serde_json::to_vec(&file)
|
let bytes = serde_json::to_vec(&file)
|
||||||
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
|
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
|
||||||
@@ -404,7 +438,7 @@ impl WatchSync {
|
|||||||
Some(file) => drive.replace(&file.uid, &bytes).await?,
|
Some(file) => drive.replace(&file.uid, &bytes).await?,
|
||||||
None => drive.create(&folders[0], &own_name, &bytes).await?,
|
None => drive.create(&folders[0], &own_name, &bytes).await?,
|
||||||
}
|
}
|
||||||
*self.own.lock() = Some(entries);
|
*self.own.lock() = Some(contents);
|
||||||
true
|
true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -435,34 +469,31 @@ impl WatchSync {
|
|||||||
uploaded,
|
uploaded,
|
||||||
devices: others.len(),
|
devices: others.len(),
|
||||||
retired: retired.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.
|
/// not moved, or `None` when it cannot be read.
|
||||||
///
|
///
|
||||||
/// A file that will not download or parse is logged and skipped: it is
|
/// 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.
|
/// another device's, and one bad file must not stop the rest syncing.
|
||||||
async fn read<D: HistoryDrive>(
|
async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> {
|
||||||
&self,
|
|
||||||
drive: &D,
|
|
||||||
file: &RemoteFile,
|
|
||||||
) -> Option<Vec<HistoryEntry>> {
|
|
||||||
let key = file.uid.link_id.as_str().to_owned();
|
let key = file.uid.link_id.as_str().to_owned();
|
||||||
if let Some(seen) = self.seen.lock().get(&key)
|
if let Some(seen) = self.seen.lock().get(&key)
|
||||||
&& file.revision.is_some()
|
&& file.revision.is_some()
|
||||||
&& seen.revision == file.revision
|
&& seen.revision == file.revision
|
||||||
{
|
{
|
||||||
return seen.entries.clone();
|
return seen.contents.clone();
|
||||||
}
|
}
|
||||||
let uid = &file.uid;
|
let uid = &file.uid;
|
||||||
let entries = match drive.download(uid).await {
|
let contents = match drive.download(uid).await {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let entries = parse(&bytes);
|
let contents = parse(&bytes);
|
||||||
if entries.is_none() {
|
if contents.is_none() {
|
||||||
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
||||||
}
|
}
|
||||||
entries
|
contents
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// Not cached: a download that failed is tried again next time.
|
// Not cached: a download that failed is tried again next time.
|
||||||
@@ -474,10 +505,10 @@ impl WatchSync {
|
|||||||
key,
|
key,
|
||||||
Seen {
|
Seen {
|
||||||
revision: file.revision.clone(),
|
revision: file.revision.clone(),
|
||||||
entries: entries.clone(),
|
contents: contents.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
entries
|
contents
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The history folders, resolved once per account.
|
/// 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.
|
/// A history file's contents, or `None` for one this build cannot read.
|
||||||
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
|
fn parse(bytes: &[u8]) -> Option<Contents> {
|
||||||
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
|
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.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::profile::tests::Device;
|
||||||
|
|
||||||
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
|
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
|
||||||
HistoryEntry {
|
HistoryEntry {
|
||||||
@@ -655,11 +690,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// An installation: its sync and its catalog.
|
/// An installation: its sync and its catalog.
|
||||||
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
|
fn device(id: &str) -> (WatchSync, Device) {
|
||||||
(
|
(WatchSync::with_installation(id.to_owned()), Device::new(id))
|
||||||
WatchSync::with_installation(id.to_owned()),
|
|
||||||
Mutex::new(Catalog::in_memory().unwrap()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
|
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
|
||||||
@@ -684,11 +716,14 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let first = laptop
|
let first = laptop
|
||||||
.sync_with(&drive, &laptop_catalog, 100)
|
.sync_with(&drive, &laptop_catalog.profile(), 100)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(first.uploaded);
|
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.applied, 1);
|
||||||
assert_eq!(second.devices, 1);
|
assert_eq!(second.devices, 1);
|
||||||
@@ -714,12 +749,15 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
laptop
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
|
|
||||||
laptop
|
laptop
|
||||||
.sync_with(&drive, &laptop_catalog, 300)
|
.sync_with(&drive, &laptop_catalog.profile(), 300)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -740,8 +778,14 @@ mod tests {
|
|||||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
laptop
|
||||||
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
.sync_with(&drive, &catalog.profile(), 100)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let again = laptop
|
||||||
|
.sync_with(&drive, &catalog.profile(), 200)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(!again.uploaded);
|
assert!(!again.uploaded);
|
||||||
assert_eq!(drive.uploads(), 1);
|
assert_eq!(drive.uploads(), 1);
|
||||||
@@ -751,7 +795,10 @@ mod tests {
|
|||||||
async fn nothing_is_uploaded_before_anything_was_watched() {
|
async fn nothing_is_uploaded_before_anything_was_watched() {
|
||||||
let drive = FakeDrive::default();
|
let drive = FakeDrive::default();
|
||||||
let (laptop, catalog) = device("laptop");
|
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!(!report.uploaded);
|
||||||
assert!(drive.names().is_empty());
|
assert!(drive.names().is_empty());
|
||||||
}
|
}
|
||||||
@@ -764,11 +811,17 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||||
.unwrap();
|
.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();
|
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
|
||||||
drive.trash(&own).await.unwrap();
|
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!(report.uploaded);
|
||||||
assert_eq!(drive.names(), ["laptop.json"]);
|
assert_eq!(drive.names(), ["laptop.json"]);
|
||||||
@@ -782,11 +835,17 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||||
.unwrap();
|
.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.
|
// The catalog was deleted; the config, and with it the id, was not.
|
||||||
let (relaunched, empty) = device("laptop");
|
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_eq!(report.applied, 1);
|
||||||
assert!(!report.uploaded);
|
assert!(!report.uploaded);
|
||||||
@@ -804,7 +863,10 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
laptop
|
||||||
|
.sync_with(&drive, &catalog.profile(), 100)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let (relaunched, other) = device("laptop");
|
let (relaunched, other) = device("laptop");
|
||||||
other
|
other
|
||||||
@@ -814,13 +876,19 @@ mod tests {
|
|||||||
drive
|
drive
|
||||||
.offline
|
.offline
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
.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);
|
assert!(!report.uploaded);
|
||||||
|
|
||||||
drive
|
drive
|
||||||
.offline
|
.offline
|
||||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
.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();
|
let state = other.lock().all_watch_states().unwrap();
|
||||||
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
|
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
|
||||||
}
|
}
|
||||||
@@ -832,6 +900,7 @@ mod tests {
|
|||||||
version: FORMAT_VERSION + 1,
|
version: FORMAT_VERSION + 1,
|
||||||
installation: "future".to_owned(),
|
installation: "future".to_owned(),
|
||||||
entries: vec![entry("ep1", 60.0, 100)],
|
entries: vec![entry("ep1", 60.0, 100)],
|
||||||
|
registers: Registers::new(),
|
||||||
};
|
};
|
||||||
drive.put("future.json", serde_json::to_vec(&future).unwrap());
|
drive.put("future.json", serde_json::to_vec(&future).unwrap());
|
||||||
let (laptop, catalog) = device("laptop");
|
let (laptop, catalog) = device("laptop");
|
||||||
@@ -841,7 +910,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let report = laptop
|
let report = laptop
|
||||||
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
|
.sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -860,21 +929,24 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
drive.at(100);
|
drive.at(100);
|
||||||
old_phone
|
old_phone
|
||||||
.sync_with(&drive, &old_catalog, 100)
|
.sync_with(&drive, &old_catalog.profile(), 100)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (phone, catalog) = device("phone");
|
let (phone, catalog) = device("phone");
|
||||||
let later = 100 + RETIRE_AFTER_SECS;
|
let later = 100 + RETIRE_AFTER_SECS;
|
||||||
drive.at(later);
|
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!(report.retired, 1);
|
||||||
assert_eq!(drive.names(), ["phone.json"]);
|
assert_eq!(drive.names(), ["phone.json"]);
|
||||||
// Nothing was lost: the newcomer's file carries it now.
|
// Nothing was lost: the newcomer's file carries it now.
|
||||||
let (fresh, fresh_catalog) = device("fresh");
|
let (fresh, fresh_catalog) = device("fresh");
|
||||||
fresh
|
fresh
|
||||||
.sync_with(&drive, &fresh_catalog, later)
|
.sync_with(&drive, &fresh_catalog.profile(), later)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -893,13 +965,13 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
drive.at(100);
|
drive.at(100);
|
||||||
laptop
|
laptop
|
||||||
.sync_with(&drive, &laptop_catalog, 100)
|
.sync_with(&drive, &laptop_catalog.profile(), 100)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (phone, catalog) = device("phone");
|
let (phone, catalog) = device("phone");
|
||||||
let report = phone
|
let report = phone
|
||||||
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
|
.sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -925,9 +997,13 @@ mod tests {
|
|||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
installation: "abc".to_owned(),
|
installation: "abc".to_owned(),
|
||||||
entries: vec![entry("a", 12.5, 100)],
|
entries: vec![entry("a", 12.5, 100)],
|
||||||
|
registers: Registers::new(),
|
||||||
};
|
};
|
||||||
let bytes = serde_json::to_vec(&file).unwrap();
|
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]
|
#[test]
|
||||||
@@ -936,6 +1012,7 @@ mod tests {
|
|||||||
version: FORMAT_VERSION + 1,
|
version: FORMAT_VERSION + 1,
|
||||||
installation: "abc".to_owned(),
|
installation: "abc".to_owned(),
|
||||||
entries: vec![entry("a", 12.5, 100)],
|
entries: vec![entry("a", 12.5, 100)],
|
||||||
|
registers: Registers::new(),
|
||||||
};
|
};
|
||||||
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
|
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
|
||||||
assert_eq!(parse(b"not json"), None);
|
assert_eq!(parse(b"not json"), None);
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ pub use player::{
|
|||||||
};
|
};
|
||||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||||
pub use render::VideoRenderer;
|
pub use render::VideoRenderer;
|
||||||
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
pub use tracks::{LANGUAGES, Track, TrackKind, full_subtitles_instead, language_name, pick_track};
|
||||||
@@ -217,6 +217,9 @@ pub struct Player {
|
|||||||
/// no stream callback can be running.
|
/// no stream callback can be running.
|
||||||
mpv: Mpv,
|
mpv: Mpv,
|
||||||
registry: Arc<StreamRegistry>,
|
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 {
|
impl Player {
|
||||||
@@ -298,7 +301,11 @@ impl Player {
|
|||||||
|
|
||||||
protocol::register(&mpv, ®istry)?;
|
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()?;
|
player.observe_playback_properties()?;
|
||||||
Ok(player)
|
Ok(player)
|
||||||
}
|
}
|
||||||
@@ -325,11 +332,64 @@ impl Player {
|
|||||||
/// The returned handle keeps the URL resolvable; drop it once the file is
|
/// The returned handle keeps the URL resolvable; drop it once the file is
|
||||||
/// done with. Dropping it does not interrupt playback already underway.
|
/// done with. Dropping it does not interrupt playback already underway.
|
||||||
pub fn play(&self, stream: VideoStream) -> Result<StreamHandle> {
|
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);
|
let handle = self.registry.publish(stream);
|
||||||
self.mpv.command("loadfile", &[&handle.url()])?;
|
self.mpv.command("loadfile", &[&handle.url()])?;
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a subtitle file to the one playing, read through the same
|
||||||
|
/// registry as the video, so mpv never learns where either came from.
|
||||||
|
///
|
||||||
|
/// Only once the video has loaded: mpv attaches an external track to the
|
||||||
|
/// file open now. `select` turns it on; otherwise it waits in the list.
|
||||||
|
/// The handle keeps it readable, like [`Self::play`]'s.
|
||||||
|
pub fn add_subtitle(
|
||||||
|
&self,
|
||||||
|
stream: VideoStream,
|
||||||
|
title: &str,
|
||||||
|
language: Option<&str>,
|
||||||
|
select: bool,
|
||||||
|
) -> Result<StreamHandle> {
|
||||||
|
let handle = self.registry.publish(stream);
|
||||||
|
let url = handle.url();
|
||||||
|
let flag = if select { "select" } else { "auto" };
|
||||||
|
let mut args = vec![url.as_str(), flag, title];
|
||||||
|
args.extend(language);
|
||||||
|
self.mpv.command("sub-add", &args)?;
|
||||||
|
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
|
/// Play anything else mpv can open: a local path, or one of its own
|
||||||
/// pseudo-protocols.
|
/// pseudo-protocols.
|
||||||
///
|
///
|
||||||
@@ -351,7 +411,15 @@ impl Player {
|
|||||||
/// property that briefly has no value is normal, not fatal.
|
/// property that briefly has no value is normal, not fatal.
|
||||||
pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> {
|
pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> {
|
||||||
match self.mpv.wait_event(timeout)? {
|
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) => {
|
Err(error) => {
|
||||||
tracing::debug!(%error, "mpv event carried an error");
|
tracing::debug!(%error, "mpv event carried an error");
|
||||||
Some(PlayerEvent::Other)
|
Some(PlayerEvent::Other)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! an empty string.
|
//! an empty string.
|
||||||
|
|
||||||
use libmpv2::Mpv;
|
use libmpv2::Mpv;
|
||||||
|
use pstr_core::subtitles::{SubtitleTrack, full_instead};
|
||||||
|
|
||||||
/// What a track carries.
|
/// What a track carries.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -69,6 +70,9 @@ pub struct Track {
|
|||||||
pub selected: bool,
|
pub selected: bool,
|
||||||
/// Whether the container marks it as the default choice.
|
/// Whether the container marks it as the default choice.
|
||||||
pub default: bool,
|
pub default: bool,
|
||||||
|
/// Whether the container marks it as forced: a subtitle track of the
|
||||||
|
/// on-screen text alone.
|
||||||
|
pub forced: bool,
|
||||||
/// True for a track added from outside the file — a sidecar subtitle. None
|
/// True for a track added from outside the file — a sidecar subtitle. None
|
||||||
/// of ours are, yet.
|
/// of ours are, yet.
|
||||||
pub external: bool,
|
pub external: bool,
|
||||||
@@ -152,9 +156,22 @@ pub fn pick_track(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether two container tags name the same language: "en" and "eng" do.
|
/// The dialogue track to play in place of the subtitle track mpv chose, when
|
||||||
fn same_language(a: &str, b: &str) -> bool {
|
/// mpv chose signs and songs; see [`pstr_core::subtitles::full_instead`].
|
||||||
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
pub fn full_subtitles_instead(tracks: &[Track]) -> Option<i64> {
|
||||||
|
let subtitles: Vec<_> = tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|track| track.kind == TrackKind::Subtitle)
|
||||||
|
.map(|track| SubtitleTrack {
|
||||||
|
id: track.id,
|
||||||
|
language: track.language.clone(),
|
||||||
|
title: track.title.clone(),
|
||||||
|
default: track.default,
|
||||||
|
forced: track.forced,
|
||||||
|
selected: track.selected,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
full_instead(&subtitles)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||||
@@ -172,6 +189,7 @@ fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
|||||||
codec: text(mpv, index, "codec"),
|
codec: text(mpv, index, "codec"),
|
||||||
selected: flag(mpv, index, "selected"),
|
selected: flag(mpv, index, "selected"),
|
||||||
default: flag(mpv, index, "default"),
|
default: flag(mpv, index, "default"),
|
||||||
|
forced: flag(mpv, index, "forced"),
|
||||||
external: flag(mpv, index, "external"),
|
external: flag(mpv, index, "external"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -190,63 +208,7 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A readable name for a language tag, falling back to the tag itself.
|
pub use pstr_core::language::{LANGUAGES, language_name, same_language};
|
||||||
///
|
|
||||||
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
|
||||||
/// the shares this app is pointed at actually contain, and an unrecognised tag
|
|
||||||
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
|
|
||||||
/// on its own.
|
|
||||||
pub fn language_name(tag: &str) -> String {
|
|
||||||
// Container tags come in both the two- and three-letter forms, and often
|
|
||||||
// with a region suffix: "en", "eng", "en-US", "pt-BR".
|
|
||||||
let base = tag
|
|
||||||
.split(['-', '_'])
|
|
||||||
.next()
|
|
||||||
.unwrap_or(tag)
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
|
|
||||||
let name = match base.as_str() {
|
|
||||||
"en" | "eng" => "English",
|
|
||||||
"ja" | "jpn" => "Japanese",
|
|
||||||
"de" | "ger" | "deu" => "German",
|
|
||||||
"fr" | "fre" | "fra" => "French",
|
|
||||||
"es" | "spa" => "Spanish",
|
|
||||||
"it" | "ita" => "Italian",
|
|
||||||
"pt" | "por" => "Portuguese",
|
|
||||||
"nl" | "dut" | "nld" => "Dutch",
|
|
||||||
"ru" | "rus" => "Russian",
|
|
||||||
"pl" | "pol" => "Polish",
|
|
||||||
"sv" | "swe" => "Swedish",
|
|
||||||
"da" | "dan" => "Danish",
|
|
||||||
"no" | "nor" => "Norwegian",
|
|
||||||
"fi" | "fin" => "Finnish",
|
|
||||||
"cs" | "cze" | "ces" => "Czech",
|
|
||||||
"hu" | "hun" => "Hungarian",
|
|
||||||
"tr" | "tur" => "Turkish",
|
|
||||||
"ar" | "ara" => "Arabic",
|
|
||||||
"he" | "heb" => "Hebrew",
|
|
||||||
"hi" | "hin" => "Hindi",
|
|
||||||
"ko" | "kor" => "Korean",
|
|
||||||
"zh" | "chi" | "zho" => "Chinese",
|
|
||||||
"th" | "tha" => "Thai",
|
|
||||||
"vi" | "vie" => "Vietnamese",
|
|
||||||
"uk" | "ukr" => "Ukrainian",
|
|
||||||
"ro" | "rum" | "ron" => "Romanian",
|
|
||||||
"el" | "gre" | "ell" => "Greek",
|
|
||||||
"id" | "ind" => "Indonesian",
|
|
||||||
"ms" | "may" | "msa" => "Malay",
|
|
||||||
_ => return tag.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// The region matters when there is one — Brazilian Portuguese is not
|
|
||||||
// Portuguese to anyone choosing between the two.
|
|
||||||
match tag.split_once(['-', '_']) {
|
|
||||||
Some((_, region)) if !region.is_empty() => {
|
|
||||||
format!("{name} ({})", region.to_ascii_uppercase())
|
|
||||||
}
|
|
||||||
_ => name.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -261,6 +223,7 @@ mod tests {
|
|||||||
codec: None,
|
codec: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
default: false,
|
default: false,
|
||||||
|
forced: false,
|
||||||
external: false,
|
external: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,16 +245,6 @@ mod tests {
|
|||||||
assert_eq!(sub.label(), "English — Signs & Songs [ass]");
|
assert_eq!(sub.label(), "English — Signs & Songs [ass]");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
|
|
||||||
assert_eq!(language_name("jpn"), "Japanese");
|
|
||||||
assert_eq!(language_name("ja"), "Japanese");
|
|
||||||
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
|
|
||||||
// An unknown tag is shown as it stands rather than as "Unknown", which
|
|
||||||
// would make two of them indistinguishable.
|
|
||||||
assert_eq!(language_name("mis"), "mis");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
||||||
Track {
|
Track {
|
||||||
language: language.map(Into::into),
|
language: language.map(Into::into),
|
||||||
|
|||||||
@@ -223,20 +223,48 @@ impl VideoStream {
|
|||||||
/// seek, and a seek cancels read-ahead — which here would cancel the very
|
/// seek, and a seek cancels read-ahead — which here would cancel the very
|
||||||
/// fetch it was meant to have started.
|
/// fetch it was meant to have started.
|
||||||
pub fn warm_edges(&self) {
|
pub fn warm_edges(&self) {
|
||||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let count = self.inner.map.len();
|
let count = self.inner.map.len();
|
||||||
let edges: &[usize] = match count {
|
let edges: &[usize] = match count {
|
||||||
0 => &[],
|
0 => &[],
|
||||||
1 => &[0],
|
1 => &[0],
|
||||||
_ => &[0, count - 1],
|
_ => &[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);
|
let inner = Arc::clone(&self.inner);
|
||||||
runtime.spawn(async move {
|
runtime.spawn(async move {
|
||||||
if let Err(error) = block_at(inner, index).await {
|
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);
|
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]
|
#[tokio::test]
|
||||||
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
|
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
|
||||||
let disk = disk_in("fill").await;
|
let disk = disk_in("fill").await;
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ screens, while UniFFI exposes the existing catalog, share, streaming, watch-stat
|
|||||||
and offline logic. Keep reusable behavior in the Rust crates rather than
|
and offline logic. Keep reusable behavior in the Rust crates rather than
|
||||||
reimplementing it in Kotlin.
|
reimplementing it in Kotlin.
|
||||||
|
|
||||||
|
## Android TV
|
||||||
|
|
||||||
|
The same build installs on Android TV: it is listed in the TV launcher
|
||||||
|
(`LEANBACK_LAUNCHER`, with the banner `scripts/render-tv-banner.sh` renders)
|
||||||
|
and requires neither a touch screen nor leanback. Every screen is Compose
|
||||||
|
`clickable`s, which the D-pad already moves between. The player takes a
|
||||||
|
remote's keys while its controls are down: left and right seek ten seconds,
|
||||||
|
the middle button brings the controls up, and play/pause works as on a
|
||||||
|
keyboard. There is no TV-specific layout yet, and none of it has been run on a
|
||||||
|
TV. Signing in to an account, so that shares arrive through sync, is the way
|
||||||
|
to set one up without typing a share link on a remote.
|
||||||
|
|
||||||
## Toolchain
|
## Toolchain
|
||||||
|
|
||||||
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
||||||
@@ -55,15 +67,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
|
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_*`
|
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,
|
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 —
|
download cancellation/resume, and an offline launch with networking disabled —
|
||||||
is `scripts/android-acceptance.sh`; `--list` prints every case and what it
|
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,
|
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
|
given as `PSTR_ACCEPTANCE_SHARE_URL` and `PSTR_ACCEPTANCE_SHARE_PASSWORD`; they
|
||||||
reach the device as instrumentation arguments and are stored nowhere. Without
|
reach the device as instrumentation arguments and are stored nowhere. Without
|
||||||
them those cases report `skip`. `picture-in-picture` is still `pending`, so the
|
them those cases report `skip`. There is no Picture-in-Picture: it was removed
|
||||||
run tells you the matrix is incomplete — but no longer because of B40, which is
|
after a PiP window that never sized its video right proved less use than
|
||||||
fixed; the case now just needs writing. `docs/TESTING.md` has the layer-by-layer
|
background audio and the lock-screen transport. `docs/TESTING.md` has the layer-by-layer
|
||||||
detail, including why lint runs against a baseline.
|
detail, including why lint runs against a baseline.
|
||||||
|
|
||||||
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
|
Requires JDK 17. `build-android.sh` locates it, because a newer JVM does not
|
||||||
@@ -110,6 +122,6 @@ ABIs, plus a source archive and revision record, under
|
|||||||
packages the staged libraries, and compiles `pstr_mpv`, the JNI/EGL adapter. It
|
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
|
feeds libmpv through the Rust stream C ABI; decrypted bytes remain in native
|
||||||
memory. `PlaybackService` owns the mpv core for background audio and media
|
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
|
Do not publish an Android binary until this exact pipeline is verified on real
|
||||||
arm64 hardware and an x86_64 emulator.
|
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
|
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
|
history is merged into this one's. Folders of the account added as shares
|
||||||
are only ever read.
|
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
|
- 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
|
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,
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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**
|
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.
|
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
|
What remains is one item: the asynchronous track-list read-back.
|
||||||
`SettingsStore` construction on the main thread.
|
|
||||||
|
|
||||||
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an
|
- `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;
|
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()`,
|
unreachable. The constructor is private behind `NativeMpvHost.createOrNull()`,
|
||||||
the service holds a nullable host, and the binder hands back a nullable one —
|
the service holds a nullable host, and the binder hands back a nullable one —
|
||||||
which is what `PlayerScreen` was already written for.
|
which is what `PlayerScreen` was already written for.
|
||||||
- `PlaybackService.kt:43` constructs `SettingsStore` on the main thread inside a
|
- **Landed.** `PlaybackService.kt:43` constructs `SettingsStore` on the main
|
||||||
hot callback; the first SharedPreferences access is disk I/O.
|
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
|
- **Landed.** `MainActivity` set `bound` only in `onServiceConnected`, so an
|
||||||
activity destroyed with a bind still pending never unbound. It is now set from
|
activity destroyed with a bind still pending never unbound. It is now set from
|
||||||
the `bindService` return value.
|
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
|
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.
|
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
|
**Still open, and the reason this entry stays open.** The matrix has not been
|
||||||
`pending`. It is no longer blocked on the app —
|
run on hardware since the 2026-10 redesign of every screen, and
|
||||||
[B40](#b40--picture-in-picture-was-entered-but-never-adapted-to) landed, so
|
`process-recreation` has not been re-run since
|
||||||
there is correct behaviour to assert — but the live suite drives the engine and
|
[B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
|
||||||
libmpv directly, and PiP is a property of the window, so the case needs an
|
landed. The `picture-in-picture` case that was `pending` below went with
|
||||||
activity-level driver that does not exist yet. Every run still prints that the
|
Picture-in-Picture itself, which was later removed (`docs/ANDROID.md`), so
|
||||||
matrix is incomplete. `process-recreation` has been promoted off its `xfail`
|
nothing is `pending` any more.
|
||||||
now that [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
|
|
||||||
has landed, and has not been re-run on hardware since.
|
|
||||||
|
|
||||||
**Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the
|
**Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the
|
||||||
signed minified release APK:
|
signed minified release APK:
|
||||||
@@ -146,6 +145,27 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
|||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
### B65 — the desktop app quit at start on a variable CJK font
|
||||||
|
|
||||||
|
**Symptom.** On Secureblue (Fedora Atomic) the window showed for a moment and
|
||||||
|
the process panicked: `Error parsing "system-cjk-fallback" TTF/OTF font file:
|
||||||
|
Invalid index 262144 for font collection`
|
||||||
|
([#1](https://github.com/narrrl/proton-stream/issues/1)).
|
||||||
|
|
||||||
|
**Cause.** The CJK fallback is whatever `fc-match "Noto Sans CJK JP"` names,
|
||||||
|
with the face taken from `%{index}`. For a variable font fontconfig packs the
|
||||||
|
named instance into that number's high 16 bits; Fedora ships Noto Sans CJK as a
|
||||||
|
variable font, so it answered 262144 — instance 4 of face 0. egui took it as a
|
||||||
|
face index, found no such face, and panics on a font it cannot parse.
|
||||||
|
|
||||||
|
**Fix.** `theme.rs` keeps only the low 16 bits, the face. And the fallback is
|
||||||
|
parsed with skrifa, as egui parses it, before it is handed over; one that fails
|
||||||
|
is logged and left out, so no installed font can stop the app again.
|
||||||
|
|
||||||
|
**Verified.** Workspace gate, with
|
||||||
|
`a_variable_fonts_named_instance_is_not_taken_for_a_face` and
|
||||||
|
`a_fallback_font_egui_cannot_read_is_left_out`. Not yet run on Secureblue.
|
||||||
|
|
||||||
### B64 — Android offline downloads looked stuck
|
### B64 — Android offline downloads looked stuck
|
||||||
|
|
||||||
**Symptom.** A queued episode, and more so a queued season, sat at a progress
|
**Symptom.** A queued episode, and more so a queued season, sat at a progress
|
||||||
@@ -1289,6 +1309,11 @@ out rather than leaving them mid-timeout.
|
|||||||
suppressed. The `picture-in-picture` acceptance case is now implementable — it
|
suppressed. The `picture-in-picture` acceptance case is now implementable — it
|
||||||
was `pending` precisely because the assertion would have had to assert this bug.
|
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
|
### B45 — the play/pause button did nothing, everywhere
|
||||||
|
|
||||||
**Symptom.** Once an episode was open, every transport that can pause it was
|
**Symptom.** Once an episode was open, every transport that can pause it was
|
||||||
|
|||||||
@@ -15,10 +15,7 @@
|
|||||||
|
|
||||||
## Build order from here
|
## Build order from here
|
||||||
|
|
||||||
1. **Sidecar subtitle files** — see the "not done" note at the end of this
|
1. Packaging is in place (`packaging/README.md`); what is left there is running
|
||||||
section. The work is in `pstr-core`: crawl them, and pair them to a video by
|
|
||||||
name.
|
|
||||||
2. Packaging is in place (`packaging/README.md`); what is left there is running
|
|
||||||
the macOS half on a real host, and a release workflow that drives every
|
the macOS half on a real host, and a release workflow that drives every
|
||||||
platform.
|
platform.
|
||||||
|
|
||||||
@@ -151,12 +148,20 @@ Note the glyph trap: egui's bundled fonts cover the transport icons
|
|||||||
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
||||||
on a button wants checking on screen before it ships.
|
on a button wants checking on screen before it ships.
|
||||||
|
|
||||||
Not done: **sidecar subtitle files**. A `.srt`/`.ass` sitting beside the video in
|
**Sidecar subtitle files.** A `.srt`, `.ass`, `.ssa` or `.vtt` beside a video
|
||||||
the share is invisible to all of this — the crawl stores video files only, so
|
is paired with it by name at crawl time (`catalog::build_sidecars`): the video's
|
||||||
there is nothing to offer in the menu. The player side is nearly free once the
|
name followed by a dot or nothing (`S01E01.en.forced.ass`), the same inside a
|
||||||
catalog has them (`sub-add pstr://<token>` through the same registry, since mpv
|
`Subs` folder beside the videos, the RARBG layout `Subs/<video>/2_English.srt`,
|
||||||
reads an external subtitle through the same protocol); the work is in
|
and failing those the only video in the folder. The words past the video's
|
||||||
`pstr-core`: crawl them, and pair them to a video by name.
|
name give the language and the picker's title. They are stored in `sidecars`
|
||||||
|
(schema v12), rebuilt by every crawl like `nodes`.
|
||||||
|
|
||||||
|
Each client opens them while mpv opens the video and adds them with `sub-add
|
||||||
|
pstr://<token>` through the same registry once mpv reports the file loaded. One
|
||||||
|
is turned on only when subtitles are on and the file has none of its own
|
||||||
|
playing, and only in the preferred language (`catalog::preferred_sidecar`); the
|
||||||
|
rest wait in the subtitle menu. A downloaded episode played without a session
|
||||||
|
goes without them, and they are not downloaded with it.
|
||||||
|
|
||||||
## Design decisions already taken
|
## Design decisions already taken
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# iOS Development
|
||||||
|
|
||||||
|
The iOS client lives in `ios/`. It is a SwiftUI port of the Android client, screen
|
||||||
|
for screen and string for string, over the same Rust engine: `crates/pstr-android`
|
||||||
|
builds unchanged for `aarch64-apple-ios` as a static library, and UniFFI writes its
|
||||||
|
Swift bindings (module `PstrBridge`). It targets iOS 17 and newer on iPhone and
|
||||||
|
iPad. Keep reusable behaviour in the Rust crates; when Android changes, the iOS
|
||||||
|
file of the same name is the one to change with it.
|
||||||
|
|
||||||
|
| Android | iOS |
|
||||||
|
|---|---|
|
||||||
|
| `ui/AppViewModel.kt` | `App/AppModel.swift` |
|
||||||
|
| `ui/ProtonStreamApp.kt` | `App/ProtonStreamApp.swift` (`RootView`) |
|
||||||
|
| `native/NativeRuntime.kt`, `native/KeystoreSecretStore.kt` | `App/NativeRuntime.swift`, `App/KeychainSecretStore.swift` |
|
||||||
|
| `settings/SettingsStore.kt` | `App/SettingsStore.swift` (same keys, in `UserDefaults`) |
|
||||||
|
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
|
||||||
|
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
|
||||||
|
| `download/*` | `Download/*` |
|
||||||
|
| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` |
|
||||||
|
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
|
||||||
|
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
|
||||||
|
|
||||||
|
Playback is libmpv from [MPVKit](https://github.com/mpvkit/MPVKit)'s GPL build,
|
||||||
|
rendering through MoltenVK into a `CAMetalLayer`, with the `pstr://` stream protocol
|
||||||
|
reading decrypted blocks straight out of Rust as on Android.
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
macOS with Xcode 16 or newer, XcodeGen, and Rust 1.96 with the iOS targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
|
||||||
|
```
|
||||||
|
|
||||||
|
The Xcode project and `Info.plist` are generated from `ios/project.yml` and are not
|
||||||
|
committed, nor are the bindings under `ios/build/generated`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/build-ios.sh test # simulator static library, bindings, unit tests
|
||||||
|
scripts/build-ios.sh all # device static library, bindings, dist/proton-stream-<version>-ios.ipa
|
||||||
|
```
|
||||||
|
|
||||||
|
`all` is `rust`, `bindings` and `ipa` in order; each can be run on its own. After
|
||||||
|
`bindings` and an `xcodegen generate` in `ios/`, the project opens in Xcode as usual,
|
||||||
|
where a run on a device needs a development team set on the target.
|
||||||
|
|
||||||
|
The `.ipa` is unsigned. SideStore and AltStore re-sign what they install with the
|
||||||
|
viewer's own Apple ID, which is also why the app has no extensions: a free Apple ID
|
||||||
|
gets few app IDs, and each extension costs one.
|
||||||
|
|
||||||
|
`.github/workflows/ios.yml` runs the tests and builds the `.ipa` on `macos-15` for
|
||||||
|
every push to `main` and every pull request that touches the iOS sources or the Rust
|
||||||
|
crates under them. `release.yml` calls it too, and publishes the `.ipa` next to the
|
||||||
|
other artifacts.
|
||||||
|
|
||||||
|
`scripts/render-ios-icon.sh` renders `ios/ProtonStream/Resources/AppIcon.svg`, which
|
||||||
|
is Android's adaptive launcher icon with both layers on one canvas, into the asset
|
||||||
|
catalog.
|
||||||
|
|
||||||
|
## Where iOS differs
|
||||||
|
|
||||||
|
Each of these is the platform's doing, not a choice to diverge.
|
||||||
|
|
||||||
|
- **No Picture-in-Picture.** libmpv draws into a Metal layer, and AVKit's
|
||||||
|
Picture-in-Picture only takes frames from `AVPlayerLayer` or
|
||||||
|
`AVSampleBufferDisplayLayer`. Background audio and the lock-screen transport work
|
||||||
|
as on Android.
|
||||||
|
- **No share sheet.** A share extension would cost a second app ID. A link reaches
|
||||||
|
the app through its URL scheme — `protonstream://add?url=<link>`, or the link with
|
||||||
|
`https` swapped for `protonstream` — or through **Paste link** in the Add share
|
||||||
|
form, which finds a link anywhere in the clipboard's text.
|
||||||
|
- **Downloads run while the app does.** A transfer is a Rust task, not a
|
||||||
|
`URLSession` task, so iOS has nothing to hand it to in the background. A download
|
||||||
|
keeps going for the grace iOS gives a backgrounded app, then stops at a block
|
||||||
|
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.
|
||||||
|
- **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.
|
||||||
|
- **New-episode checks run when iOS chooses.** Android's `NewEpisodesWorker`
|
||||||
|
crawls every six hours on a network. iOS's `BGProcessingTask` is asked for no
|
||||||
|
sooner than six hours, and the system decides when, mostly overnight on a
|
||||||
|
charger. An app refresh would come more often but gets about thirty seconds,
|
||||||
|
which a crawl of a large share does not fit in.
|
||||||
|
- **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
|
||||||
|
identity.
|
||||||
@@ -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 |
|
| `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 |
|
| `skip` | needs a share that was not configured |
|
||||||
|
|
||||||
Today `process-recreation` is `xfail` against B35, and `picture-in-picture` is
|
Today `process-recreation` is `xfail` against B35 — asserting today's behaviour
|
||||||
`pending` against B40 — asserting today's behaviour would encode the bug rather
|
would encode the bug rather than catch it. The `picture-in-picture` case went
|
||||||
than catch it. **The matrix is not complete, and the runner says so on every
|
with Picture-in-Picture itself, so nothing is `pending`.
|
||||||
run.**
|
|
||||||
|
|
||||||
The five live cases need a share, given as environment variables that reach the
|
The five live cases need a share, given as environment variables that reach the
|
||||||
device as instrumentation arguments and are written nowhere:
|
device as instrumentation arguments and are written nowhere:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# proton-stream for iOS license
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
Copyright (c) proton-stream contributors.
|
||||||
|
|
||||||
|
The iOS application under this directory is free software: you may
|
||||||
|
redistribute it and/or modify it under the terms of the GNU General Public
|
||||||
|
License as published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||||
|
for the complete license text.
|
||||||
|
|
||||||
|
This licensing choice applies to the iOS application and its distribution,
|
||||||
|
including the combined native player. Rust crates outside `ios/` retain the
|
||||||
|
licenses declared in their manifests; the repository root `LICENSE` remains the
|
||||||
|
MIT license for those components.
|
||||||
@@ -1,140 +1,874 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// The app's one Rust engine and the screen state read from it.
|
/// The Proton account: where a sign-in stands, and how sync is doing.
|
||||||
|
struct AccountUiState: Equatable {
|
||||||
|
/// Nil until the bridge has said.
|
||||||
|
var state: AccountState?
|
||||||
|
/// A sign-in step is out and not answered yet.
|
||||||
|
var busy = false
|
||||||
|
/// Why the last sign-in step was refused.
|
||||||
|
var error: String?
|
||||||
|
/// The CAPTCHA page Proton wants solved before it accepts the sign-in.
|
||||||
|
var verificationUrl: String?
|
||||||
|
/// When watch history last synced.
|
||||||
|
var syncedAt: Date?
|
||||||
|
/// Positions the last sync took from other devices.
|
||||||
|
var applied = 0
|
||||||
|
/// Why the last sync failed, until one succeeds.
|
||||||
|
var syncError: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode's watch state as it was before a change the viewer can undo.
|
||||||
|
/// Nil `state` means it had never been played.
|
||||||
|
struct WatchSnapshot: Equatable {
|
||||||
|
let shareId: String
|
||||||
|
let linkId: String
|
||||||
|
let state: WatchStateRecord?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's screen state and every action on it: Android's `AppViewModel`,
|
||||||
|
/// field for field and message for message.
|
||||||
///
|
///
|
||||||
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only
|
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this asks it
|
||||||
/// asks it questions off the main thread and keeps the answers for SwiftUI.
|
/// questions off the main thread and keeps the answers for SwiftUI.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class AppModel {
|
final class AppModel {
|
||||||
let engine: AndroidEngine?
|
var loading = true
|
||||||
private(set) var startupError: String?
|
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] = []
|
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?
|
||||||
|
/// As the viewer last left them.
|
||||||
|
var filter: LibraryFilter = SettingsStore().libraryFilter
|
||||||
|
var sort: LibrarySort = SettingsStore().librarySort
|
||||||
|
var grouped = SettingsStore().libraryGrouped
|
||||||
var shares: [ShareRecord] = []
|
var shares: [ShareRecord] = []
|
||||||
var search = ""
|
var offline: [OfflineRecord] = []
|
||||||
var crawling = false
|
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
|
||||||
var error: String?
|
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()
|
||||||
|
|
||||||
init() {
|
@ObservationIgnored private var searchTask: Task<Void, Never>?
|
||||||
do {
|
@ObservationIgnored private var started = false
|
||||||
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
|
/// Launch and the first `.active` both ask; one crawl answers.
|
||||||
} catch {
|
@ObservationIgnored private var catchingUp = false
|
||||||
engine = nil
|
/// Every banner line whose work is still running, oldest first.
|
||||||
startupError = String(describing: error)
|
@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.
|
||||||
|
private static let syncInterval: Duration = .seconds(5 * 60)
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
if started { return }
|
||||||
|
started = true
|
||||||
|
reload()
|
||||||
|
enteredForeground()
|
||||||
|
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
|
||||||
|
DownloadCoordinator.shared.resumeQueued()
|
||||||
|
Task {
|
||||||
|
let state = (try? await run { $0.accountState() }) ?? .signedOut
|
||||||
|
account.state = state
|
||||||
|
// While the app is alive, on top of after sign-in and whenever the
|
||||||
|
// player closes.
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await syncWatchHistory()
|
||||||
|
try? await Task.sleep(for: Self.syncInterval)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Application Support survives updates and is backed up, which is what
|
|
||||||
/// the watch history wants. The block cache goes under Caches, which iOS
|
|
||||||
/// may purge and never backs up.
|
|
||||||
private static func paths() throws -> AndroidPaths {
|
|
||||||
let files = FileManager.default
|
|
||||||
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let config = support.appendingPathComponent("config", isDirectory: true)
|
|
||||||
let data = support.appendingPathComponent("data", isDirectory: true)
|
|
||||||
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
|
|
||||||
for directory in [config, data, cache] {
|
|
||||||
try files.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NoEngine: Error, CustomStringConvertible {
|
|
||||||
var description: String { "the Rust engine did not start" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a blocking bridge call off the main thread. The synchronous exports
|
/// Run a blocking bridge call off the main thread. The synchronous exports
|
||||||
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
|
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
|
||||||
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T {
|
nonisolated func run<T: Sendable>(_ work: @escaping @Sendable (AndroidEngine) throws -> T) async throws -> T {
|
||||||
guard let engine else { throw NoEngine() }
|
try await Task.detached(priority: .userInitiated) {
|
||||||
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
|
try work(try NativeRuntime.blockingEngine())
|
||||||
|
}.value
|
||||||
}
|
}
|
||||||
|
|
||||||
func report(_ error: Error) {
|
// MARK: - Library
|
||||||
self.error = String(describing: error)
|
|
||||||
|
/// Reorder the grid, or fold or unfold its franchises.
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func reload() async {
|
/// Narrow the grid to some titles, and keep the choice for the next launch.
|
||||||
let needle = search.trimmingCharacters(in: .whitespaces)
|
func setFilter(_ filter: LibraryFilter) {
|
||||||
do {
|
self.filter = filter
|
||||||
let (titles, shares) = try await run { engine in
|
SettingsStore().libraryFilter = filter
|
||||||
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
|
Task { await reloadLibrary(query) }
|
||||||
}
|
}
|
||||||
self.titles = titles
|
|
||||||
self.shares = shares
|
func search(_ query: String) {
|
||||||
} catch {
|
self.query = query
|
||||||
report(error)
|
searchTask?.cancel()
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await reloadLibrary(query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func crawl(shareId: String? = nil) async {
|
func refresh() {
|
||||||
guard let engine, !crawling else { return }
|
crawl(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recrawl one share rather than the whole library: a share whose link has
|
||||||
|
/// expired makes a whole-library refresh fail, and must not leave no way to
|
||||||
|
/// refresh the shares that still work.
|
||||||
|
func refreshShare(_ id: String) {
|
||||||
|
crawl(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func crawl(_ shareId: String?) {
|
||||||
|
Task { await crawlNow(shareId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
|
||||||
|
///
|
||||||
|
/// Held under a background task: a crawl takes minutes, and without one
|
||||||
|
/// iOS suspends the app the moment it leaves the screen and tears down its
|
||||||
|
/// connections, so a glance at another app failed the crawl.
|
||||||
|
private func crawlShares(_ id: String?) async throws {
|
||||||
|
crawlsRunning += 1
|
||||||
crawling = true
|
crawling = true
|
||||||
defer { crawling = false }
|
let task = BackgroundTask(name: "crawl")
|
||||||
do {
|
defer {
|
||||||
try await engine.crawl(shareId: shareId)
|
task.end()
|
||||||
} catch {
|
crawlsRunning -= 1
|
||||||
report(error)
|
crawling = crawlsRunning > 0
|
||||||
}
|
}
|
||||||
await reload()
|
try await NativeRuntime.engine().crawl(shareId: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addShare(name: String, url: String, password: String?) async {
|
/// Stop the crawls running now. Shares already read stay in the library;
|
||||||
do {
|
/// the one being read keeps what it had.
|
||||||
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
func stopCrawl() {
|
||||||
await crawl(shareId: share.id)
|
crawlStopped = true
|
||||||
} catch {
|
Task { try? await NativeRuntime.engine().stopCrawl() }
|
||||||
report(error)
|
}
|
||||||
|
|
||||||
|
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||||
|
/// this returns.
|
||||||
|
func crawlNow(_ shareId: String?) async {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func repairShare(id: String, url: String, password: String?) async {
|
/// Run enrichment again. With `force` every title is looked up afresh,
|
||||||
|
/// including ones that already matched.
|
||||||
|
func matchTitles(force: Bool) {
|
||||||
|
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)
|
||||||
|
let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0
|
||||||
|
if !quiet || found {
|
||||||
|
message = Self.describe(summary)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 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 {
|
do {
|
||||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password)
|
||||||
await crawl(shareId: id)
|
|
||||||
} catch {
|
} catch {
|
||||||
report(error)
|
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))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeShare(id: String) async {
|
/// Re-supply the link behind a share whose stored secret cannot be read.
|
||||||
do {
|
/// Re-entering the link rewrites the secret and leaves the catalog and the
|
||||||
try await run { try $0.removeShare(shareId: id) }
|
/// offline files alone.
|
||||||
} catch {
|
func repairShare(id: String, url: String, password: String?) {
|
||||||
report(error)
|
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()
|
||||||
}
|
}
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marking seen puts the position at the duration where one is known, and
|
/// Call a share something else. The library, history and downloads stay.
|
||||||
/// unwatching rewinds, as on desktop and Android.
|
func renameShare(id: String, name: String) {
|
||||||
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await run { try $0.renameShare(shareId: id, name: name) }
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeShare(_ id: String) {
|
||||||
|
Task {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissMessage() {
|
||||||
|
message = nil
|
||||||
|
undo = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account
|
||||||
|
|
||||||
|
/// Start signing in, or start again with `verificationToken` once the
|
||||||
|
/// CAPTCHA from a first try is solved.
|
||||||
|
func signIn(_ username: String, _ password: String, verificationToken: String? = nil) {
|
||||||
|
signInStep { try await $0.signIn(username: username, password: password, verificationToken: verificationToken) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitSecondFactor(_ code: String) {
|
||||||
|
signInStep { try await $0.submitSecondFactor(code: code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitMailboxPassword(_ password: String) {
|
||||||
|
signInStep(keepStepOnFailure: true) { try await $0.submitMailboxPassword(password: password) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelSignIn() {
|
||||||
|
account = AccountUiState(state: .signedOut)
|
||||||
|
Task { try? await run { $0.cancelSignIn() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CAPTCHA page closed without a token.
|
||||||
|
func dismissVerification() {
|
||||||
|
account.verificationUrl = nil
|
||||||
|
account.busy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signInStep(keepStepOnFailure: Bool = false, _ step: @escaping (AndroidEngine) async throws -> SignInOutcome) {
|
||||||
|
account.busy = true
|
||||||
|
account.error = nil
|
||||||
|
account.verificationUrl = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let outcome = try await step(NativeRuntime.engine())
|
||||||
|
switch outcome {
|
||||||
|
case let .done(username):
|
||||||
|
account = AccountUiState(state: .signedIn(username: username))
|
||||||
|
message = "Signed in as \(username)"
|
||||||
|
await syncWatchHistory()
|
||||||
|
reload()
|
||||||
|
case .secondFactor:
|
||||||
|
account.state = .secondFactor
|
||||||
|
account.busy = false
|
||||||
|
case .mailboxPassword:
|
||||||
|
account.state = .mailboxPassword
|
||||||
|
account.busy = false
|
||||||
|
case let .humanVerification(url):
|
||||||
|
account.verificationUrl = url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
account.busy = false
|
||||||
|
account.error = errorMessage(error)
|
||||||
|
// A refused code spends the half-made session.
|
||||||
|
if !keepStepOnFailure { account.state = .signedOut }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() {
|
||||||
|
Task {
|
||||||
|
await busy("Signing out…") {
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().signOut()
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
account = AccountUiState(state: .signedOut)
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync now, from a tap: says so when it is done.
|
||||||
|
func syncNow() {
|
||||||
|
Task {
|
||||||
|
var synced = false
|
||||||
|
await busy("Syncing with your other devices…") { synced = await syncWatchHistory() }
|
||||||
|
guard synced else { return }
|
||||||
|
let applied = account.applied
|
||||||
|
message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 }
|
||||||
do {
|
do {
|
||||||
try await run { engine in
|
guard let report = try await NativeRuntime.engine().syncWatchHistory() else { return false }
|
||||||
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
|
account.syncedAt = Date()
|
||||||
try engine.saveWatchState(
|
account.applied = Int(report.applied)
|
||||||
shareId: episode.shareId, linkId: episode.linkId,
|
account.syncError = nil
|
||||||
positionSecs: watched ? duration ?? 0 : 0,
|
await takeSynced(report)
|
||||||
durationSecs: duration, watched: watched
|
return true
|
||||||
|
} catch {
|
||||||
|
let state = try? await run { $0.accountState() }
|
||||||
|
if let state { account.state = state }
|
||||||
|
account.syncError = errorMessage(error)
|
||||||
|
// Signed out by the engine: the session ended, and that is worth
|
||||||
|
// interrupting for, unlike a dropped connection.
|
||||||
|
if state == .signedOut { reportError(error) }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
||||||
|
guard case .signedIn = account.state else { return }
|
||||||
|
let task = BackgroundTask(name: "watch-history-sync")
|
||||||
|
Task {
|
||||||
|
await syncWatchHistory()
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the player closes: where it stopped is what another device wants next.
|
||||||
|
func playerClosed() {
|
||||||
|
reload()
|
||||||
|
Task { await syncWatchHistory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
|
||||||
|
reload()
|
||||||
|
await crawlAdded([share.id], announce: true)
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Watch state
|
||||||
|
|
||||||
|
/// Every episode of a title watched or unwatched, with an Undo: a whole
|
||||||
|
/// 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) {
|
||||||
|
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,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
positionSecs: watched ? duration ?? 0 : 0,
|
||||||
|
durationSecs: duration,
|
||||||
|
watched: watched
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Off Continue watching by forgetting where the episode stopped — what the
|
||||||
|
/// desktop does — with an Undo that puts the position back.
|
||||||
|
func forgetPosition(_ title: TitleRecord, _ episode: EpisodeRecord) {
|
||||||
|
changeWatch([episode], "\(title.displayName) removed from Continue watching") { engine, target, before in
|
||||||
|
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Off the history page: the position and the watched mark both go, which
|
||||||
|
/// is "never played", with an Undo.
|
||||||
|
func removeFromHistory(_ title: TitleRecord, _ episode: EpisodeRecord) {
|
||||||
|
changeWatch([episode], "\(episode.label) of \(title.displayName) removed from history") { engine, target, before in
|
||||||
|
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put back what the last undoable change replaced.
|
||||||
|
func performUndo() {
|
||||||
|
guard let snapshots = undo else { return }
|
||||||
|
message = nil
|
||||||
|
undo = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await run { engine in
|
||||||
|
for snapshot in snapshots {
|
||||||
|
try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changeWatch(
|
||||||
|
_ episodes: [EpisodeRecord],
|
||||||
|
_ text: String,
|
||||||
|
_ change: @escaping @Sendable (AndroidEngine, EpisodeRecord, WatchStateRecord?) throws -> Void
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let snapshots = try await run { engine in
|
||||||
|
try episodes.map { episode in
|
||||||
|
let before = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
|
||||||
|
try change(engine, episode, before)
|
||||||
|
return WatchSnapshot(shareId: episode.shareId, linkId: episode.linkId, state: before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message = text
|
||||||
|
undo = snapshots
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportError(_ error: Error) {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(_ text: String) {
|
||||||
|
message = text
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||||
|
return try engine.metadataSettings()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorMessage(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
if enabled {
|
||||||
|
Task { await match(force: false) }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadAfterMetadataChange() { reload() }
|
||||||
|
|
||||||
|
/// What an enrichment pass did, in one line. Failures are reported even
|
||||||
|
/// when most titles matched.
|
||||||
|
static func describe(_ summary: MatchSummary) -> String {
|
||||||
|
if summary.matched == 0, summary.unmatched == 0, summary.failed == 0 {
|
||||||
|
return "Everything is already matched"
|
||||||
|
}
|
||||||
|
var parts = ["\(summary.matched) matched"]
|
||||||
|
if summary.unmatched > 0 { parts.append("\(summary.unmatched) not found") }
|
||||||
|
if summary.episodes > 0 { parts.append("\(summary.episodes) episodes named") }
|
||||||
|
if summary.failed > 0 { parts.append("\(summary.failed) failed") }
|
||||||
|
return parts.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline
|
||||||
|
|
||||||
|
func removeOffline(_ file: OfflineRecord) {
|
||||||
|
Task {
|
||||||
|
await busy("Deleting the download…") {
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.pause(download) }
|
||||||
|
|
||||||
|
func resumeDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.resume(download) }
|
||||||
|
|
||||||
|
func deletePartial(_ download: RetainedDownload) {
|
||||||
|
Task {
|
||||||
|
await DownloadCoordinator.shared.cancelAndWait(download)
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
|
||||||
|
DownloadStateStore.shared.remove(download.shareId, download.linkId)
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllOffline() {
|
||||||
|
Task {
|
||||||
|
await busy("Deleting every download…") {
|
||||||
|
await DownloadCoordinator.shared.cancelAll()
|
||||||
|
do {
|
||||||
|
try await NativeRuntime.engine().removeAllOffline()
|
||||||
|
DownloadStateStore.shared.clear()
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
await reloadNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearBlockCache() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let reclaimed = try await run { try $0.clearBlockCache() }
|
||||||
|
message = "Reclaimed \(formatBytes(reclaimed)) of cache"
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
/// Persist where an episode was left. Runs on its own task rather than the
|
||||||
|
/// player's, so a save issued on the way out is not cancelled by the
|
||||||
|
/// player going away.
|
||||||
|
func saveProgress(_ episode: EpisodeRecord, position: Double, duration: Double, watched: Bool) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await run { engine in
|
||||||
|
try engine.saveWatchState(
|
||||||
|
shareId: episode.shareId,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
positionSecs: min(max(position, 0), duration),
|
||||||
|
durationSecs: duration,
|
||||||
|
watched: watched
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an episode seen, or unseen, without playing it.
|
||||||
|
///
|
||||||
|
/// 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return season
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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 {
|
} catch {
|
||||||
report(error)
|
loading = false
|
||||||
|
message = errorMessage(error)
|
||||||
}
|
}
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Episodes started but not finished, most recent first.
|
private func reloadLibrary(_ query: String) async {
|
||||||
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
|
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
|
||||||
titles
|
let filter = filter
|
||||||
.flatMap { title in
|
let sort = sort
|
||||||
title.seasons.flatMap(\.episodes)
|
let grouped = grouped
|
||||||
.filter { $0.resumeAt != nil && !$0.watched }
|
do {
|
||||||
.map { (title: title, episode: $0) }
|
let (titles, arrangement) = try await run { engine in
|
||||||
|
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
|
||||||
}
|
}
|
||||||
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
|
self.titles = titles
|
||||||
|
self.arrangement = arrangement
|
||||||
|
} catch {
|
||||||
|
message = errorMessage(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The grace iOS gives an app leaving the screen, ended exactly once —
|
||||||
|
/// whether by the work finishing or by the time running out.
|
||||||
|
@MainActor
|
||||||
|
final class BackgroundTask {
|
||||||
|
private var identifier = UIBackgroundTaskIdentifier.invalid
|
||||||
|
|
||||||
|
init(name: String) {
|
||||||
|
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
MainActor.assumeIsolated { self?.end() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard identifier != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(identifier)
|
||||||
|
identifier = .invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Reloaded: @unchecked Sendable {
|
||||||
|
let shares: [ShareRecord]
|
||||||
|
let titles: [TitleRecord]
|
||||||
|
let arrangement: ArrangementRecord
|
||||||
|
let offline: [OfflineRecord]
|
||||||
|
let metadataSettings: MetadataSettingsRecord
|
||||||
|
let storage: StorageUsageRecord
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A Proton Drive public link, wherever in a piece of text it sits.
|
||||||
|
///
|
||||||
|
/// The fragment is kept: it is the share's secret, and a link without it cannot
|
||||||
|
/// be opened. Matching stops at whitespace and at the quotes and brackets a
|
||||||
|
/// message app wraps a link in, which never occur in a link itself.
|
||||||
|
private let shareLinkPattern = #"https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+"#
|
||||||
|
|
||||||
|
/// The first share link in `text`, if any — for the clipboard.
|
||||||
|
func shareLink(in text: String) -> String? {
|
||||||
|
text.range(of: shareLinkPattern, options: .regularExpression).map { String(text[$0]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share link a URL opened in this app carries, if any.
|
||||||
|
///
|
||||||
|
/// iOS hands an app no `https://drive.proton.me` link — Universal Links belong
|
||||||
|
/// to the domain's owner — so a link arrives through the app's own scheme:
|
||||||
|
/// `protonstream://add?url=<link>`, or the link itself with its scheme swapped,
|
||||||
|
/// `protonstream://drive.proton.me/urls/…#…`.
|
||||||
|
func shareLink(from url: URL) -> String? {
|
||||||
|
guard url.scheme?.lowercased() == "protonstream" else { return nil }
|
||||||
|
if url.host == "add" {
|
||||||
|
let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?.first { $0.name == "url" }?.value
|
||||||
|
return value.flatMap(wholeShareLink)
|
||||||
|
}
|
||||||
|
let text = url.absoluteString
|
||||||
|
let rest = text[text.index(text.startIndex, offsetBy: "protonstream://".count)...]
|
||||||
|
return wholeShareLink("https://\(rest)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `value` when the whole of it is a share link — what `ACTION_VIEW` accepts on
|
||||||
|
/// Android — rather than a link somewhere inside it.
|
||||||
|
private func wholeShareLink(_ value: String) -> String? {
|
||||||
|
guard let range = value.range(of: shareLinkPattern, options: .regularExpression),
|
||||||
|
range == value.startIndex ..< value.endIndex
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Process-wide owner of the Rust engine; records and decrypted media remain
|
||||||
|
/// app-private. The counterpart of Android's `NativeRuntime`.
|
||||||
|
enum NativeRuntime {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var instance: AndroidEngine?
|
||||||
|
|
||||||
|
/// The engine, built on first use off the main thread: construction opens
|
||||||
|
/// SQLite, spins a Tokio runtime and reads the Keychain.
|
||||||
|
static func engine() async throws -> AndroidEngine {
|
||||||
|
try await Task.detached(priority: .userInitiated) { try blockingEngine() }.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine from a thread that may block — a bridge callback, a
|
||||||
|
/// background task. Never call this on the main thread.
|
||||||
|
static func blockingEngine() throws -> AndroidEngine {
|
||||||
|
try lock.withLock {
|
||||||
|
if let instance { return instance }
|
||||||
|
let engine = try AndroidEngine(paths: try paths(), secrets: KeychainSecretStore())
|
||||||
|
// The budget is this app's setting, so the engine starts on the
|
||||||
|
// default and is told the chosen one before anything streams.
|
||||||
|
engine.setStreamCacheBudget(bytes: SettingsStore.gibToBytes(SettingsStore().cacheBudgetGib))
|
||||||
|
instance = engine
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored palette, without building the engine.
|
||||||
|
///
|
||||||
|
/// Callable from the main thread, and meant to be: it is the only palette
|
||||||
|
/// read early enough to decide what the first frame is painted in, and
|
||||||
|
/// `engine()` is not. This reads one small JSON file and resolves it.
|
||||||
|
static func initialPalette() -> PaletteRecord? {
|
||||||
|
guard let paths = try? paths() else { return nil }
|
||||||
|
return storedPalette(paths: paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application Support for the catalog and the configuration, Caches for
|
||||||
|
/// the block cache, which iOS may purge.
|
||||||
|
///
|
||||||
|
/// Neither the catalog nor the configuration is backed up, as on Android
|
||||||
|
/// (`allowBackup="false"`): the share secrets live in the Keychain as
|
||||||
|
/// this-device-only items, so a restored share list would arrive without
|
||||||
|
/// them, and a restored `sync.json` would give two phones one sync identity
|
||||||
|
/// writing one file — the case `pstr-core::sync` exists to prevent.
|
||||||
|
private static func paths() throws -> AndroidPaths {
|
||||||
|
let files = FileManager.default
|
||||||
|
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
var config = support.appendingPathComponent("config", isDirectory: true)
|
||||||
|
var data = support.appendingPathComponent("data", isDirectory: true)
|
||||||
|
let cache = caches.appendingPathComponent("stream", isDirectory: true)
|
||||||
|
for directory in [config, data, cache] {
|
||||||
|
try files.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
var excluded = URLResourceValues()
|
||||||
|
excluded.isExcludedFromBackup = true
|
||||||
|
try config.setResourceValues(excluded)
|
||||||
|
try data.setResourceValues(excluded)
|
||||||
|
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a bridge failure says, without Swift's type name around it.
|
||||||
|
///
|
||||||
|
/// `BridgeError`'s own description is `String(reflecting:)`, which reads
|
||||||
|
/// `PstrBridge.BridgeError.Failure(reason: "…")` in a snackbar.
|
||||||
|
func errorMessage(_ error: Error) -> String {
|
||||||
|
if case let BridgeError.Failure(reason) = error { return reason }
|
||||||
|
let text = error.localizedDescription
|
||||||
|
return text.isEmpty ? "Unexpected error" : text
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Look at the shares while the app is away, and say when a show gained
|
||||||
|
/// episodes — what `NewEpisodesWorker` does on Android.
|
||||||
|
///
|
||||||
|
/// A processing task rather than an app refresh: a crawl of a large share
|
||||||
|
/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it
|
||||||
|
/// when it sees fit, mostly overnight on a charger, so it is a best effort
|
||||||
|
/// where Android's is a schedule.
|
||||||
|
enum NewEpisodes {
|
||||||
|
static let identifier = "io.narl.protonstream.new-episodes"
|
||||||
|
private static let interval: TimeInterval = 6 * 60 * 60
|
||||||
|
|
||||||
|
/// At launch, before the app finishes launching: iOS refuses a handler
|
||||||
|
/// registered later.
|
||||||
|
static func register() {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
||||||
|
guard let task = task as? BGProcessingTask else { return }
|
||||||
|
run(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the next look scheduled, or cancel it when the viewer turned it off.
|
||||||
|
static func schedule() {
|
||||||
|
guard SettingsStore().newEpisodeNotifications else {
|
||||||
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let request = BGProcessingTaskRequest(identifier: identifier)
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask once, when the viewer has the setting on; iOS remembers the answer.
|
||||||
|
static func requestPermission() {
|
||||||
|
guard SettingsStore().newEpisodeNotifications else { return }
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func run(_ task: BGProcessingTask) {
|
||||||
|
schedule()
|
||||||
|
let work = Task {
|
||||||
|
let news = (try? await NativeRuntime.engine().crawlForNews()) ?? []
|
||||||
|
await post(news)
|
||||||
|
task.setTaskCompleted(success: !Task.isCancelled)
|
||||||
|
}
|
||||||
|
task.expirationHandler = {
|
||||||
|
work.cancel()
|
||||||
|
Task { try? await NativeRuntime.engine().stopCrawl() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ news: [NewEpisodesRecord]) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
guard await center.notificationSettings().authorizationStatus == .authorized else { return }
|
||||||
|
for item in news {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = item.title
|
||||||
|
content.body = describe(item)
|
||||||
|
content.threadIdentifier = "new-episodes"
|
||||||
|
// Keyed by the show, so a second look's news replaces the first.
|
||||||
|
let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil)
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one show's notification says: "S01E04 and 2 more are new".
|
||||||
|
static func describe(_ news: NewEpisodesRecord) -> String {
|
||||||
|
let more = Int(news.count) - 1
|
||||||
|
guard let first = news.first else {
|
||||||
|
return news.count == 1 ? "1 new episode" : "\(news.count) new episodes"
|
||||||
|
}
|
||||||
|
switch more {
|
||||||
|
case 0: return "\(first) is new"
|
||||||
|
case 1: return "\(first) and 1 more are new"
|
||||||
|
default: return "\(first) and \(more) more are new"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,399 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ProtonStreamApp: App {
|
struct ProtonStreamApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var model = AppModel()
|
@State private var model = AppModel()
|
||||||
|
@State private var appearance = AppearanceState.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// `.playback` keeps the audio going with the screen locked or the app
|
// `.playback` keeps the audio going with the screen locked or the app
|
||||||
// in the background, which `UIBackgroundModes: audio` then permits.
|
// in the background, which `UIBackgroundModes: audio` then permits.
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||||
|
// The palette read before the first frame, so the first frame is
|
||||||
|
// already painted in it.
|
||||||
|
AppearanceState.shared.seed(NativeRuntime.initialPalette())
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
let scheme = appearance.scheme
|
||||||
RootView()
|
RootView()
|
||||||
.environment(model)
|
.environment(model)
|
||||||
.preferredColorScheme(.dark)
|
.environment(\.scheme, scheme)
|
||||||
|
.preferredColorScheme(scheme.colorScheme)
|
||||||
|
.tint(scheme.primary)
|
||||||
|
.font(.inter(.bodyLarge))
|
||||||
|
.background(scheme.background.ignoresSafeArea())
|
||||||
|
.task { await appearance.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
NewEpisodes.register()
|
||||||
|
NewEpisodes.schedule()
|
||||||
|
NewEpisodes.requestPermission()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
||||||
|
MainActor.assumeIsolated { OrientationLock.mask }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From this width the library and an open title share the window.
|
||||||
|
private let twoPaneWidth: CGFloat = 840
|
||||||
|
|
||||||
|
/// From this width the destinations are a rail down the side, not a bar.
|
||||||
|
private let railWidth: CGFloat = 600
|
||||||
|
|
||||||
|
/// How much room the floating mini transport needs at the foot of a page.
|
||||||
|
private let miniTransportInset: CGFloat = 88
|
||||||
|
|
||||||
|
enum Destination: String, CaseIterable {
|
||||||
|
case library, history, shares, downloads, settings
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .library: "Library"
|
||||||
|
case .history: "History"
|
||||||
|
case .shares: "Shares"
|
||||||
|
case .downloads: "Downloads"
|
||||||
|
case .settings: "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .library: "play.rectangle.on.rectangle.fill"
|
||||||
|
case .history: "clock.arrow.circlepath"
|
||||||
|
case .shares: "square.and.arrow.up"
|
||||||
|
case .downloads: "arrow.down.circle"
|
||||||
|
case .settings: "gearshape"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(AppModel.self) private var model
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@Environment(\.scenePhase) private var phase
|
||||||
|
// Keys, not records: what has to survive is *which* title, and a record is
|
||||||
|
// no longer current after the library reloads. Everything else is derived,
|
||||||
|
// so a reload that renames a season updates the open screen.
|
||||||
|
@SceneStorage("destination") private var destination: Destination = .library
|
||||||
|
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
|
||||||
|
@State private var incomingLink: String?
|
||||||
|
@State private var host = PlayerHost.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let failure = model.startupError {
|
GeometryReader { geometry in
|
||||||
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
|
let wide = geometry.size.width >= railWidth
|
||||||
} else {
|
ZStack(alignment: .bottom) {
|
||||||
TabView {
|
HStack(spacing: 0) {
|
||||||
NavigationStack { LibraryView() }
|
if wide { NavigationRail(destination: $destination, onSelect: select) }
|
||||||
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
|
page(width: geometry.size.width)
|
||||||
NavigationStack { SharesView() }
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
.tabItem { Label("Shares", systemImage: "link") }
|
VStack(spacing: 8) {
|
||||||
NavigationStack { SettingsView() }
|
if host.isOpen && host.minimized {
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
MiniTransport(host: host)
|
||||||
|
}
|
||||||
|
if !wide { NavigationBar(destination: $destination, onSelect: select) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ActivityBanner()
|
||||||
|
Snackbar()
|
||||||
|
}
|
||||||
|
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: model.activity)
|
||||||
}
|
}
|
||||||
.task { await model.reload() }
|
.background(scheme.background.ignoresSafeArea())
|
||||||
.alert("Something went wrong", isPresented: .init(
|
// Covered by the player, but still there to VoiceOver, which
|
||||||
get: { model.error != nil },
|
// would otherwise walk the library behind the picture.
|
||||||
set: { if !$0 { model.error = nil } }
|
.accessibilityHidden(host.isOpen && !host.minimized)
|
||||||
)) {
|
}
|
||||||
Button("OK", role: .cancel) {}
|
.overlay {
|
||||||
} message: {
|
// Over the shell rather than instead of it: the page behind stays
|
||||||
Text(model.error ?? "")
|
// where it was, so leaving the player finds the title page as it was.
|
||||||
|
if host.isOpen && !host.minimized {
|
||||||
|
Group {
|
||||||
|
if let player = host.player {
|
||||||
|
PlayerView(host: host, player: player)
|
||||||
|
} else {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
Text(host.playbackError ?? "Playback is unavailable: libmpv did not start.")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
IconButton("xmark", "Close", tint: .white, action: host.close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: host.isOpen && !host.minimized)
|
||||||
|
.onAppear {
|
||||||
|
model.start()
|
||||||
|
host.onSaveProgress = { [model] episode, position, duration, watched in
|
||||||
|
model.saveProgress(episode, position: position, duration: duration, watched: watched)
|
||||||
|
}
|
||||||
|
host.onClosed = { [model] in model.playerClosed() }
|
||||||
|
}
|
||||||
|
.onChange(of: model.titles) { _, titles in host.update(titles: titles) }
|
||||||
|
.onChange(of: phase) { _, phase in
|
||||||
|
switch phase {
|
||||||
|
case .background:
|
||||||
|
host.enteredBackground()
|
||||||
|
model.syncInBackground()
|
||||||
|
case .active:
|
||||||
|
host.enteredForeground()
|
||||||
|
model.enteredForeground()
|
||||||
|
DownloadCoordinator.shared.resumeQueued()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A link opened in this app: show the Add form with it filled in. A
|
||||||
|
// playing episode keeps going in the mini transport rather than
|
||||||
|
// covering the form.
|
||||||
|
.onOpenURL { url in
|
||||||
|
guard let link = shareLink(from: url) else { return }
|
||||||
|
incomingLink = link
|
||||||
|
destination = .shares
|
||||||
|
selectedTitleKey = nil
|
||||||
|
if host.isOpen { host.minimized = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func select(_ target: Destination) {
|
||||||
|
destination = target
|
||||||
|
selectedTitleKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func play(_ title: TitleRecord, _ index: Int) {
|
||||||
|
host.play(title, at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func page(width: CGFloat) -> some View {
|
||||||
|
switch destination {
|
||||||
|
case .library:
|
||||||
|
libraryPage(width: width)
|
||||||
|
case .history:
|
||||||
|
HistoryView(onPlay: play, onTitle: { title in
|
||||||
|
destination = .library
|
||||||
|
selectedTitleKey = title.key
|
||||||
|
})
|
||||||
|
case .shares:
|
||||||
|
// 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(onPlay: play, onOpen: { title in
|
||||||
|
destination = .library
|
||||||
|
selectedTitleKey = title.key
|
||||||
|
})
|
||||||
|
case .settings:
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Side by side where there is room for both: on a tablet the library
|
||||||
|
/// stays in view while a title is open, so moving between titles is one
|
||||||
|
/// 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 } },
|
||||||
|
// 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)
|
||||||
|
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
|
||||||
|
Group {
|
||||||
|
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The library stays underneath the title page rather than being
|
||||||
|
// rebuilt, so going back finds the grid scrolled where it was.
|
||||||
|
ZStack {
|
||||||
|
library
|
||||||
|
.opacity(selected == nil ? 1 : 0)
|
||||||
|
.allowsHitTesting(selected == nil)
|
||||||
|
.accessibilityHidden(selected != nil)
|
||||||
|
if let selected {
|
||||||
|
titlePane(selected)
|
||||||
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titlePane(_ selected: TitleRecord) -> some View {
|
||||||
|
TitleView(
|
||||||
|
title: selected,
|
||||||
|
onPlay: { _, index in host.play(selected, at: index) },
|
||||||
|
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
|
||||||
|
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
|
||||||
|
onOpenTitle: { selectedTitleKey = $0.key }
|
||||||
|
)
|
||||||
|
.id(selected.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The destinations along the foot of a phone, the selected one in an accent
|
||||||
|
/// pill — the desktop's rule is that the accent marks the thing you are on.
|
||||||
|
private struct NavigationBar: View {
|
||||||
|
@Binding var destination: Destination
|
||||||
|
let onSelect: (Destination) -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Destination.allCases, id: \.self) { item in
|
||||||
|
let selected = item == destination
|
||||||
|
Button { onSelect(item) } label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
|
||||||
|
.frame(width: 56, height: 30)
|
||||||
|
.background(selected ? scheme.primary : .clear, in: Capsule())
|
||||||
|
Text(item.label)
|
||||||
|
.textStyle(.labelMedium, semibold: selected)
|
||||||
|
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.accessibilityLabel(item.label)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(scheme.surfaceContainer.ignoresSafeArea(edges: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same destinations down the side of a wide window.
|
||||||
|
private struct NavigationRail: View {
|
||||||
|
@Binding var destination: Destination
|
||||||
|
let onSelect: (Destination) -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(Destination.allCases, id: \.self) { item in
|
||||||
|
let selected = item == destination
|
||||||
|
Button { onSelect(item) } label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
|
||||||
|
.frame(width: 56, height: 32)
|
||||||
|
.background(selected ? scheme.primary : .clear, in: Capsule())
|
||||||
|
Text(item.label)
|
||||||
|
.textStyle(.labelMedium, semibold: selected)
|
||||||
|
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
.frame(width: 80)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.accessibilityLabel(item.label)
|
||||||
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
.background(scheme.surface.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let message = model.message {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(message)
|
||||||
|
.textStyle(.bodyMedium)
|
||||||
|
.foregroundStyle(scheme.inverseOnSurface)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if model.undo != nil {
|
||||||
|
Button("Undo", action: model.performUndo)
|
||||||
|
.textStyle(.labelLarge)
|
||||||
|
.foregroundStyle(scheme.inversePrimary)
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(scheme.inverseSurface, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.onTapGesture(perform: model.dismissMessage)
|
||||||
|
// Long with an Undo to reach for, short without — Material's two
|
||||||
|
// snackbar durations.
|
||||||
|
.task(id: message) {
|
||||||
|
try? await Task.sleep(for: .seconds(model.undo != nil ? 10 : 4))
|
||||||
|
if !Task.isCancelled, model.message == message { model.dismissMessage() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The settings that are this front end's alone.
|
||||||
|
///
|
||||||
|
/// Playback preferences — volume, languages, autoplay, auto-skip — deliberately
|
||||||
|
/// do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
|
||||||
|
/// there is one definition of them rather than one per front end. Keys and
|
||||||
|
/// defaults are Android's `SettingsStore`.
|
||||||
|
struct SettingsStore {
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
var wifiOnly: Bool {
|
||||||
|
get { bool(Key.wifiOnly, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.wifiOnly) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var backgroundAudio: Bool {
|
||||||
|
get { bool(Key.backgroundAudio, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.backgroundAudio) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether mpv may decode in hardware. Off is the way out for a file
|
||||||
|
/// VideoToolbox shows green or torn frames for; it costs battery, so it is
|
||||||
|
/// on unless the viewer turns it off. Read at each load, so a change applies
|
||||||
|
/// from the next file.
|
||||||
|
var hardwareDecoding: Bool {
|
||||||
|
get { bool(Key.hardwareDecoding, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.hardwareDecoding) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The streaming cache's budget, in GiB. See `cacheBudgetChoices`.
|
||||||
|
var cacheBudgetGib: Int {
|
||||||
|
get { defaults.object(forKey: Key.cacheBudgetGib) as? Int ?? Self.defaultCacheBudgetGib }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.cacheBudgetGib) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop's range, in the steps a phone's storage is bought in.
|
||||||
|
static let cacheBudgetChoices = [1, 2, 4, 8, 16]
|
||||||
|
|
||||||
|
/// `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing.
|
||||||
|
static let defaultCacheBudgetGib = 4
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a background crawl may say that a show gained episodes.
|
||||||
|
var newEpisodeNotifications: Bool {
|
||||||
|
get { bool(Key.newEpisodeNotifications, default: true) }
|
||||||
|
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Key {
|
||||||
|
static let wifiOnly = "wifi_only"
|
||||||
|
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"
|
||||||
|
static let newEpisodeNotifications = "new_episode_notifications"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Offline downloads: the queue, the retries and the network policy.
|
||||||
|
///
|
||||||
|
/// Rust owns the block-aligned transfer and the catalog mutation, as on
|
||||||
|
/// Android; this owns what WorkManager owns there. iOS has no equivalent of a
|
||||||
|
/// foreground worker for a transfer that is not a `URLSession` task, so a
|
||||||
|
/// download runs while the app does, plus the grace `beginBackgroundTask`
|
||||||
|
/// grants once it leaves the screen. One cut short is put back in the queue,
|
||||||
|
/// and the `.part` file resumes at its last whole block on the next launch.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class DownloadCoordinator {
|
||||||
|
static let shared = 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)?
|
||||||
|
|
||||||
|
@ObservationIgnored private let store = DownloadStateStore.shared
|
||||||
|
@ObservationIgnored private var running: [String: Transfer] = [:]
|
||||||
|
@ObservationIgnored private let monitor = NWPathMonitor()
|
||||||
|
@ObservationIgnored private var path: NWPath?
|
||||||
|
@ObservationIgnored private var refreshQueued = false
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
store.onChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.scheduleRefresh() }
|
||||||
|
}
|
||||||
|
records = store.records()
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor in self?.pathChanged(path) }
|
||||||
|
}
|
||||||
|
monitor.start(queue: DispatchQueue(label: "io.narl.protonstream.network"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free space to leave behind a download, for the catalog and the caches.
|
||||||
|
private static let storageHeadroom: Int64 = 256 * 1024 * 1024
|
||||||
|
private static let maxAttempts = 3
|
||||||
|
|
||||||
|
func enqueue(_ episode: EpisodeRecord) { enqueue([episode]) }
|
||||||
|
|
||||||
|
func enqueue(_ episodes: [EpisodeRecord]) {
|
||||||
|
let wanted = episodes.filter { !$0.offline }
|
||||||
|
if wanted.isEmpty { return }
|
||||||
|
// Space is checked once for the whole request, against its total.
|
||||||
|
// Queueing a season that cannot fit and discovering it episode by
|
||||||
|
// episode at ENOSPC is how the app fills its own database's disk.
|
||||||
|
let room = Self.roomFor(wanted.reduce(Int64(0)) { $0 + Int64($1.size ?? 0) })
|
||||||
|
let downloads = wanted.map { episode in
|
||||||
|
RetainedDownload(
|
||||||
|
shareId: episode.shareId,
|
||||||
|
volumeId: episode.volumeId,
|
||||||
|
linkId: episode.linkId,
|
||||||
|
label: episode.label,
|
||||||
|
total: Int64(episode.size ?? 0),
|
||||||
|
status: room ? RetainedDownload.statusQueued : RetainedDownload.statusFailed,
|
||||||
|
error: room ? nil : "Not enough free space"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// An episode already in flight keeps its record and its transfer.
|
||||||
|
store.putAll(downloads.filter { running[$0.key] == nil })
|
||||||
|
if room { downloads.forEach(start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume(_ download: RetainedDownload) {
|
||||||
|
var queued = download
|
||||||
|
queued.status = RetainedDownload.statusQueued
|
||||||
|
queued.error = nil
|
||||||
|
guard let transfer = running[download.key] else {
|
||||||
|
store.put(queued)
|
||||||
|
start(queued)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Still running: nothing to resume. Still winding down from a pause:
|
||||||
|
// let it record that first, or its last write lands over this one.
|
||||||
|
guard transfer.observer.stopped else { return }
|
||||||
|
Task {
|
||||||
|
await transfer.task.value
|
||||||
|
store.put(queued)
|
||||||
|
start(queued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause(_ download: RetainedDownload) { setStatus(download, RetainedDownload.statusPaused) }
|
||||||
|
|
||||||
|
func cancel(_ download: RetainedDownload) { setStatus(download, RetainedDownload.statusCancelled) }
|
||||||
|
|
||||||
|
/// Stop one download and wait for its transfer to let go of the `.part`
|
||||||
|
/// file, so the caller can remove it.
|
||||||
|
func cancelAndWait(_ download: RetainedDownload) async {
|
||||||
|
cancel(download)
|
||||||
|
await running[download.key]?.task.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop every download of one share, and wait for them.
|
||||||
|
func cancelShare(_ shareId: String) async {
|
||||||
|
let transfers = running.values.filter { $0.download.shareId == shareId }
|
||||||
|
for transfer in transfers { stop(transfer.download.key, as: RetainedDownload.statusCancelled) }
|
||||||
|
for transfer in transfers { await transfer.task.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop everything, and wait.
|
||||||
|
func cancelAll() async {
|
||||||
|
let transfers = Array(running.values)
|
||||||
|
for transfer in transfers { stop(transfer.download.key, as: RetainedDownload.statusCancelled) }
|
||||||
|
for transfer in transfers { await transfer.task.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start whatever is queued — on launch, back in the foreground, and when
|
||||||
|
/// the network comes back. A download the system cut short is queued, not
|
||||||
|
/// failed, so it is picked up here.
|
||||||
|
func resumeQueued() {
|
||||||
|
for download in store.records() where download.status == RetainedDownload.statusQueued
|
||||||
|
|| download.status == RetainedDownload.statusRunning {
|
||||||
|
start(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
for key in Array(running.keys) { stop(key, as: RetainedDownload.statusQueued) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Running
|
||||||
|
|
||||||
|
private final class Transfer {
|
||||||
|
let download: RetainedDownload
|
||||||
|
let observer: Observer
|
||||||
|
var task: Task<Void, Never>!
|
||||||
|
|
||||||
|
init(download: RetainedDownload, observer: Observer) {
|
||||||
|
self.download = download
|
||||||
|
self.observer = observer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the network policy lets a transfer run now.
|
||||||
|
private var allowed: Bool {
|
||||||
|
guard let path, path.status == .satisfied else { return false }
|
||||||
|
return !SettingsStore().wifiOnly || !(path.isExpensive || path.usesInterfaceType(.cellular))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pathChanged(_ path: NWPath) {
|
||||||
|
self.path = path
|
||||||
|
applyNetworkPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(_ download: RetainedDownload) {
|
||||||
|
guard running[download.key] == nil, allowed else { return }
|
||||||
|
let observer = Observer(store: store, download: download)
|
||||||
|
let transfer = Transfer(download: download, observer: observer)
|
||||||
|
running[download.key] = transfer
|
||||||
|
transfer.task = Task { [weak self] in
|
||||||
|
await self?.run(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a transfer to stop at its next block boundary. `status` is what its
|
||||||
|
/// record says afterwards; nil leaves that to whoever called.
|
||||||
|
private func stop(_ key: String, as status: String?) {
|
||||||
|
guard let transfer = running[key] else { return }
|
||||||
|
transfer.observer.stop(as: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setStatus(_ download: RetainedDownload, _ status: String) {
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var changed = stored ?? download
|
||||||
|
changed.status = status
|
||||||
|
changed.error = nil
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
stop(download.key, as: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ transfer: Transfer) async {
|
||||||
|
let download = transfer.download
|
||||||
|
let observer = transfer.observer
|
||||||
|
let background = UIApplication.shared.beginBackgroundTask(withName: "offline-\(download.linkId)") {
|
||||||
|
// Out of background time: stop at the next block and queue it, so
|
||||||
|
// the next launch carries on from the `.part` file.
|
||||||
|
observer.stop(as: RetainedDownload.statusQueued)
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
running[download.key] = nil
|
||||||
|
UIApplication.shared.endBackgroundTask(background)
|
||||||
|
}
|
||||||
|
for attempt in 1 ... Self.maxAttempts {
|
||||||
|
// A pause or a cancel that landed before this got going.
|
||||||
|
if let stored = store.get(download.shareId, download.linkId),
|
||||||
|
stored.status == RetainedDownload.statusPaused || stored.status == RetainedDownload.statusCancelled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.status = RetainedDownload.statusQueued
|
||||||
|
current.error = nil
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let engine = try await NativeRuntime.engine()
|
||||||
|
_ = try await engine.downloadEpisode(
|
||||||
|
shareId: download.shareId,
|
||||||
|
volumeId: download.volumeId,
|
||||||
|
linkId: download.linkId,
|
||||||
|
observer: observer
|
||||||
|
)
|
||||||
|
store.remove(download.shareId, download.linkId)
|
||||||
|
onFinished?()
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if let status = observer.stoppedAs {
|
||||||
|
// A pause is also a stop. Under the lock, so the record
|
||||||
|
// read here is the one the write lands on.
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
guard var current = stored, current.status != RetainedDownload.statusPaused else { return nil }
|
||||||
|
current.status = status
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if observer.stopped { return }
|
||||||
|
let message = errorMessage(error)
|
||||||
|
if attempt < Self.maxAttempts {
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.status = RetainedDownload.statusQueued
|
||||||
|
current.error = message
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
// WorkManager's exponential backoff, from 30 s.
|
||||||
|
try? await Task.sleep(for: .seconds(30 * (1 << (attempt - 1))))
|
||||||
|
if observer.stopped { return }
|
||||||
|
} else {
|
||||||
|
store.update(download.shareId, download.linkId) { stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.status = RetainedDownload.statusFailed
|
||||||
|
current.error = message
|
||||||
|
current.bytesPerSecond = 0
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRefresh() {
|
||||||
|
// Progress lands a dozen times a second on a fast link; one re-read
|
||||||
|
// per frame is plenty.
|
||||||
|
if refreshQueued { return }
|
||||||
|
refreshQueued = true
|
||||||
|
Task { @MainActor in
|
||||||
|
refreshQueued = false
|
||||||
|
records = store.records()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `bytes` plus working room will fit where offline files are kept.
|
||||||
|
/// An unknown size — zero — is allowed through.
|
||||||
|
private static func roomFor(_ bytes: Int64) -> Bool {
|
||||||
|
if bytes <= 0 { return true }
|
||||||
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
guard let values = try? support.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
|
||||||
|
let available = values.volumeAvailableCapacityForImportantUsage
|
||||||
|
else { return true }
|
||||||
|
return available - bytes >= storageHeadroom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bridge's view of one transfer: where it is, and whether to stop.
|
||||||
|
private final class Observer: DownloadObserver, @unchecked Sendable {
|
||||||
|
private let store: DownloadStateStore
|
||||||
|
private let download: RetainedDownload
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var rate = TransferRate()
|
||||||
|
private var reportedAt: Int64 = 0
|
||||||
|
private var reportedPercent = -1
|
||||||
|
private var stopRequested = false
|
||||||
|
private var stopStatus: String?
|
||||||
|
|
||||||
|
/// Shortest gap between two identical-looking progress reports.
|
||||||
|
private static let progressIntervalMs: Int64 = 1000
|
||||||
|
|
||||||
|
init(store: DownloadStateStore, download: RetainedDownload) {
|
||||||
|
self.store = store
|
||||||
|
self.download = download
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop(as status: String?) {
|
||||||
|
lock.withLock {
|
||||||
|
stopRequested = true
|
||||||
|
if let status { stopStatus = status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopped: Bool { lock.withLock { stopRequested } }
|
||||||
|
var stoppedAs: String? { lock.withLock { stopStatus } }
|
||||||
|
|
||||||
|
/// Called once per 4 MiB block. Nothing the viewer can see changes that
|
||||||
|
/// often, so a report is only made when the percentage moves or a second
|
||||||
|
/// has passed. The final one is never suppressed.
|
||||||
|
func onProgress(downloaded: UInt64, total: UInt64) throws {
|
||||||
|
let percent = total == 0 ? 0 : Int(downloaded * 100 / total)
|
||||||
|
let now = Int64(ProcessInfo.processInfo.systemUptime * 1000)
|
||||||
|
let complete = total > 0 && downloaded >= total
|
||||||
|
let report: Int64? = lock.withLock {
|
||||||
|
let bytesPerSecond = rate.sample(Int64(downloaded), now: now)
|
||||||
|
if !complete, percent == reportedPercent, now - reportedAt < Self.progressIntervalMs { return nil }
|
||||||
|
reportedAt = now
|
||||||
|
reportedPercent = percent
|
||||||
|
return bytesPerSecond
|
||||||
|
}
|
||||||
|
guard let bytesPerSecond = report else { return }
|
||||||
|
store.update(download.shareId, download.linkId) { [download] stored in
|
||||||
|
var current = stored ?? download
|
||||||
|
current.downloaded = Int64(downloaded)
|
||||||
|
current.total = Int64(total)
|
||||||
|
current.bytesPerSecond = bytesPerSecond
|
||||||
|
if current.status != RetainedDownload.statusPaused, current.status != RetainedDownload.statusCancelled {
|
||||||
|
current.status = RetainedDownload.statusRunning
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCancelled() throws -> Bool { stopped }
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Durable user-facing state for one offline download, kept until it finishes
|
||||||
|
/// or the viewer deletes it. The fields and their JSON names are Android's
|
||||||
|
/// `RetainedDownload`.
|
||||||
|
struct RetainedDownload: Equatable, Hashable, Identifiable, Sendable {
|
||||||
|
var shareId: String
|
||||||
|
var volumeId: String
|
||||||
|
var linkId: String
|
||||||
|
var label: String
|
||||||
|
var downloaded: Int64 = 0
|
||||||
|
var total: Int64 = 0
|
||||||
|
var status: String = RetainedDownload.statusQueued
|
||||||
|
var error: String?
|
||||||
|
/// Recent transfer rate while running; 0 when unknown or not running.
|
||||||
|
var bytesPerSecond: Int64 = 0
|
||||||
|
|
||||||
|
var key: String { downloadKey(shareId, linkId) }
|
||||||
|
var id: String { key }
|
||||||
|
|
||||||
|
static let statusQueued = "queued"
|
||||||
|
static let statusRunning = "running"
|
||||||
|
static let statusPaused = "paused"
|
||||||
|
static let statusFailed = "failed"
|
||||||
|
static let statusCancelled = "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadKey(_ shareId: String, _ linkId: String) -> String { "\(shareId)\u{1f}\(linkId)" }
|
||||||
|
|
||||||
|
/// The retained downloads, as one JSON file of `key → record` beside the
|
||||||
|
/// catalog — Android keeps the same records in SharedPreferences.
|
||||||
|
///
|
||||||
|
/// Every write goes through one lock: the transfer's progress reports and the
|
||||||
|
/// viewer's pause are concurrent writers to the same record, and a whole-record
|
||||||
|
/// `put` from a screen's stale copy would both lose that progress and race the
|
||||||
|
/// transfer's own read-modify-write.
|
||||||
|
final class DownloadStateStore: @unchecked Sendable {
|
||||||
|
static let shared = DownloadStateStore(file: DownloadStateStore.defaultFile)
|
||||||
|
|
||||||
|
/// Called after every write, on whichever thread wrote.
|
||||||
|
var onChange: (() -> Void)?
|
||||||
|
|
||||||
|
private let file: URL
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var entries: [String: String]?
|
||||||
|
|
||||||
|
init(file: URL) {
|
||||||
|
self.file = file
|
||||||
|
}
|
||||||
|
|
||||||
|
func records() -> [RetainedDownload] {
|
||||||
|
lock.withLock { loaded().values.compactMap(Self.decode) }
|
||||||
|
.sorted { $0.label.lowercased() < $1.label.lowercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(_ shareId: String, _ linkId: String) -> RetainedDownload? {
|
||||||
|
lock.withLock { loaded()[downloadKey(shareId, linkId)].flatMap(Self.decode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func put(_ record: RetainedDownload) {
|
||||||
|
edit { $0[record.key] = Self.encode(record) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write one record, excluding every other writer. Returning
|
||||||
|
/// nil from `change` leaves the record alone.
|
||||||
|
func update(_ shareId: String, _ linkId: String, _ change: (RetainedDownload?) -> RetainedDownload?) {
|
||||||
|
edit { entries in
|
||||||
|
let key = downloadKey(shareId, linkId)
|
||||||
|
if let changed = change(entries[key].flatMap(Self.decode)) {
|
||||||
|
entries[changed.key] = Self.encode(changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a whole queue in one go: queueing a season is one write, not one
|
||||||
|
/// per episode.
|
||||||
|
func putAll(_ records: [RetainedDownload]) {
|
||||||
|
edit { entries in
|
||||||
|
for record in records { entries[record.key] = Self.encode(record) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ shareId: String, _ linkId: String) {
|
||||||
|
edit { $0[downloadKey(shareId, linkId)] = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every retained download. Callers stop the transfers first.
|
||||||
|
func clear() {
|
||||||
|
edit { $0.removeAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeShare(_ shareId: String) {
|
||||||
|
edit { entries in
|
||||||
|
entries = entries.filter { Self.decode($0.value)?.shareId != shareId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func edit(_ change: (inout [String: String]) -> Void) {
|
||||||
|
lock.withLock {
|
||||||
|
var current = loaded()
|
||||||
|
change(¤t)
|
||||||
|
entries = current
|
||||||
|
// Atomic, so a kill mid-write leaves the last good file.
|
||||||
|
if let data = try? JSONEncoder().encode(current) {
|
||||||
|
try? data.write(to: file, options: [.atomic])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `lock`.
|
||||||
|
private func loaded() -> [String: String] {
|
||||||
|
if let entries { return entries }
|
||||||
|
let read = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
|
||||||
|
entries = read
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encode(_ record: RetainedDownload) -> String {
|
||||||
|
var json: [String: Any] = [
|
||||||
|
"share": record.shareId,
|
||||||
|
"volume": record.volumeId,
|
||||||
|
"link": record.linkId,
|
||||||
|
"label": record.label,
|
||||||
|
"downloaded": record.downloaded,
|
||||||
|
"total": record.total,
|
||||||
|
"status": record.status,
|
||||||
|
"rate": record.bytesPerSecond,
|
||||||
|
]
|
||||||
|
if let error = record.error { json["error"] = error }
|
||||||
|
let data = (try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])) ?? Data()
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row that does not decode is skipped rather than failing the list.
|
||||||
|
static func decode(_ encoded: String) -> RetainedDownload? {
|
||||||
|
guard let object = try? JSONSerialization.jsonObject(with: Data(encoded.utf8)),
|
||||||
|
let json = object as? [String: Any],
|
||||||
|
let share = json["share"] as? String,
|
||||||
|
let volume = json["volume"] as? String,
|
||||||
|
let link = json["link"] as? String
|
||||||
|
else { return nil }
|
||||||
|
let error = (json["error"] as? String).flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||||
|
return RetainedDownload(
|
||||||
|
shareId: share,
|
||||||
|
volumeId: volume,
|
||||||
|
linkId: link,
|
||||||
|
label: json["label"] as? String ?? link,
|
||||||
|
downloaded: (json["downloaded"] as? NSNumber)?.int64Value ?? 0,
|
||||||
|
total: (json["total"] as? NSNumber)?.int64Value ?? 0,
|
||||||
|
status: json["status"] as? String ?? RetainedDownload.statusQueued,
|
||||||
|
error: error,
|
||||||
|
bytesPerSecond: (json["rate"] as? NSNumber)?.int64Value ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In a directory of its own, excluded from backup like the catalog: an
|
||||||
|
/// atomic write replaces the file, so the flag has to sit on its parent.
|
||||||
|
private static var defaultFile: URL {
|
||||||
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
var directory = support.appendingPathComponent("state", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
var excluded = URLResourceValues()
|
||||||
|
excluded.isExcludedFromBackup = true
|
||||||
|
try? directory.setResourceValues(excluded)
|
||||||
|
return directory.appendingPathComponent("offline-downloads.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes per second over roughly the last few seconds.
|
||||||
|
///
|
||||||
|
/// Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||||
|
/// steps seconds apart; a rate beside it is what tells a slow download from a
|
||||||
|
/// stuck one. Smoothed, because one block that took a little longer than the
|
||||||
|
/// last would otherwise halve the figure for a moment.
|
||||||
|
struct TransferRate {
|
||||||
|
private var lastBytes: Int64 = -1
|
||||||
|
private var lastAt: Int64 = 0
|
||||||
|
private var smoothed = 0.0
|
||||||
|
|
||||||
|
/// Record `bytes` transferred at `now` (milliseconds) and return the rate.
|
||||||
|
mutating func sample(_ bytes: Int64, now: Int64) -> Int64 {
|
||||||
|
if lastBytes < 0 || bytes < lastBytes {
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return Int64(smoothed)
|
||||||
|
}
|
||||||
|
let elapsed = now - lastAt
|
||||||
|
if elapsed <= 0 { return Int64(smoothed) }
|
||||||
|
let instant = Double(bytes - lastBytes) * 1000 / Double(elapsed)
|
||||||
|
smoothed = smoothed > 0 ? 0.7 * smoothed + 0.3 * instant : instant
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return Int64(smoothed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,59 +17,133 @@ final class MpvMetalLayer: CAMetalLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the open file ended, mirroring `pstr_player::EndReason` so every client
|
||||||
|
/// agrees on what "finished" means.
|
||||||
|
///
|
||||||
|
/// Only `eof` is an episode watched to the end. Treating the others as one — a
|
||||||
|
/// stop, a shutdown, a failed demux — is what marks an episode that never
|
||||||
|
/// played as watched and autoplays past it.
|
||||||
|
enum EndReason {
|
||||||
|
case eof, stopped, quit, failed, other
|
||||||
|
}
|
||||||
|
|
||||||
struct MpvTrack: Identifiable, Hashable {
|
struct MpvTrack: Identifiable, Hashable {
|
||||||
let id: Int64
|
let id: Int64
|
||||||
let type: String
|
let type: String
|
||||||
let language: String
|
let language: String
|
||||||
let title: String
|
let title: String
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
|
/// The container marks it as the default choice.
|
||||||
|
var isDefault = false
|
||||||
|
/// The container marks it as forced: on-screen text only, by convention.
|
||||||
|
var forced = false
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
let parts = [title, language.uppercased()].filter { !$0.isEmpty }
|
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " · ")
|
return parts.isEmpty ? "\(type.prefix(1).uppercased() + type.dropFirst()) \(id)" : parts.joined(separator: " — ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One libmpv core rendering into a Metal layer. Every public method runs on
|
/// One libmpv core rendering into a Metal layer, for the life of the app.
|
||||||
/// the main thread; mpv's events are read on a thread of their own and applied
|
///
|
||||||
/// back on the main thread.
|
/// Every public method runs on the main thread; mpv's events are read on a
|
||||||
|
/// thread of their own and applied back on the main thread. The state mirrors
|
||||||
|
/// what `pstr_mpv.cpp` publishes on Android, field for field.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class MpvPlayer {
|
final class MpvPlayer {
|
||||||
enum EndReason { case eof, stopped, failed }
|
|
||||||
|
|
||||||
private(set) var position: Double = 0
|
private(set) var position: Double = 0
|
||||||
private(set) var duration: Double = 0
|
private(set) var duration: Double = 0
|
||||||
|
private(set) var volume: Double = 100
|
||||||
private(set) var paused = false
|
private(set) var paused = false
|
||||||
|
private(set) var muted = false
|
||||||
|
/// The file ended, and why. Nil while one is open.
|
||||||
|
private(set) var endReason: EndReason?
|
||||||
|
/// mpv has run out of buffered data and stopped to refill — not the same
|
||||||
|
/// as `paused`, which is the viewer's doing.
|
||||||
private(set) var buffering = false
|
private(set) var buffering = false
|
||||||
|
/// How full the demuxer cache is while `buffering`, 0–100.
|
||||||
|
private(set) var cachePercent: Double = 0
|
||||||
|
/// A seek has been issued but playback has not resumed at the new position.
|
||||||
|
private(set) var seeking = false
|
||||||
|
/// How far the demuxer has read ahead, in seconds into the file.
|
||||||
|
private(set) var cachedUntil: Double = 0
|
||||||
|
/// The picture's display size, or zero until mpv has decoded enough.
|
||||||
|
private(set) var videoWidth: Double = 0
|
||||||
|
private(set) var videoHeight: Double = 0
|
||||||
private(set) var tracks: [MpvTrack] = []
|
private(set) var tracks: [MpvTrack] = []
|
||||||
private(set) var chapters: [ChapterRecord] = []
|
private(set) var chapters: [ChapterRecord] = []
|
||||||
private(set) var failure: String?
|
/// Which episode the readings above describe, or nil between `loadfile`
|
||||||
/// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in
|
/// and mpv's `START_FILE`. Everything that *writes* per-episode state —
|
||||||
/// that window still describes the *outgoing* file, including the
|
/// the resume position above all — must check it: one core serves every
|
||||||
/// `END_FILE(stop)` stopping it emits.
|
/// episode, and a reading attributed to the wrong one resumes the next
|
||||||
private(set) var loading = false
|
/// episode where the last one stopped.
|
||||||
|
private(set) var media: String?
|
||||||
|
/// What mpv last complained about, until the viewer dismisses it.
|
||||||
|
var problem: String?
|
||||||
|
/// Bumped by every load, so a reader can tell "episode two, second zero"
|
||||||
|
/// from a leftover reading of episode one.
|
||||||
|
private(set) var generation = 0
|
||||||
|
|
||||||
|
/// Whether the picture is stopped for a reason the viewer did not choose.
|
||||||
|
var stalled: Bool { (buffering || seeking) && !paused && endReason == nil }
|
||||||
|
|
||||||
/// Called once per file that ends, never for the one a load replaced.
|
/// Called once per file that ends, never for the one a load replaced.
|
||||||
@ObservationIgnored var onEnded: ((EndReason) -> Void)?
|
@ObservationIgnored var onEnded: ((EndReason, String) -> Void)?
|
||||||
|
/// Called on every position change, for what has to react to the clock.
|
||||||
|
@ObservationIgnored var onTick: (() -> Void)?
|
||||||
|
/// Called when the duration or the pause state changes: what the lock
|
||||||
|
/// screen shows, and whether the screen may sleep.
|
||||||
|
@ObservationIgnored var onStateChanged: (() -> Void)?
|
||||||
|
/// Called once per file, when mpv has loaded it and picked its tracks.
|
||||||
|
@ObservationIgnored var onLoaded: (() -> Void)?
|
||||||
|
|
||||||
@ObservationIgnored let layer = MpvMetalLayer()
|
@ObservationIgnored let layer = MpvMetalLayer()
|
||||||
@ObservationIgnored private var mpv: OpaquePointer?
|
@ObservationIgnored private var mpv: OpaquePointer?
|
||||||
|
/// Between `loadfile` and `START_FILE`. Everything mpv reports in that
|
||||||
|
/// window still describes the *outgoing* file, including the
|
||||||
|
/// `END_FILE(stop)` that stopping it emits.
|
||||||
|
@ObservationIgnored private var loading = false
|
||||||
|
@ObservationIgnored private var pendingMedia: String?
|
||||||
|
/// The load generation whose file mpv has loaded.
|
||||||
|
@ObservationIgnored private var loadedGeneration = -1
|
||||||
|
/// Subtitle files handed over before their file loaded, and whether each
|
||||||
|
/// is the one to turn on.
|
||||||
|
@ObservationIgnored private var pendingSubtitles: [(SubtitleFileRecord, Bool)] = []
|
||||||
|
/// The drawable size mpv last built its output for; see `outputResized`.
|
||||||
|
@ObservationIgnored private var outputSize: CGSize = .zero
|
||||||
|
/// Off while the app is in the background, where a refit must not bring
|
||||||
|
/// the picture back.
|
||||||
|
@ObservationIgnored private var videoEnabled = true
|
||||||
|
@ObservationIgnored private var refitTask: Task<Void, Never>?
|
||||||
|
/// Every `vid` write, in the order they were made. Each one tears down or
|
||||||
|
/// builds mpv's video output and waits on its render thread, which the
|
||||||
|
/// main thread must not; and a refit's "auto" must never land after the
|
||||||
|
/// background's "no".
|
||||||
|
@ObservationIgnored private let videoQueue = DispatchQueue(label: "mpv-video")
|
||||||
|
|
||||||
init() {
|
/// A core, or nil when libmpv would not start.
|
||||||
|
static func create() -> MpvPlayer? {
|
||||||
|
let player = MpvPlayer()
|
||||||
|
return player.mpv == nil ? nil : player
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
layer.framebufferOnly = true
|
layer.framebufferOnly = true
|
||||||
layer.backgroundColor = UIColor.black.cgColor
|
layer.backgroundColor = UIColor.black.cgColor
|
||||||
guard let mpv = mpv_create() else {
|
// For the frames between a resize and mpv's next one: a drawable of
|
||||||
failure = "libmpv did not start"
|
// the old shape is letterboxed rather than stretched.
|
||||||
return
|
layer.contentsGravity = .resizeAspect
|
||||||
}
|
guard let mpv = mpv_create() else { return }
|
||||||
var wid = layer
|
// mpv takes the layer's address as an int64, not a Swift reference.
|
||||||
|
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
|
||||||
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
|
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("config", "no"),
|
("config", "no"),
|
||||||
("vo", "gpu-next"),
|
("vo", "gpu-next"),
|
||||||
("gpu-api", "vulkan"),
|
("gpu-api", "vulkan"),
|
||||||
("gpu-context", "moltenvk"),
|
("gpu-context", "moltenvk"),
|
||||||
|
// The default; `load` sets it per file from the viewer's setting.
|
||||||
("hwdec", "videotoolbox"),
|
("hwdec", "videotoolbox"),
|
||||||
("video-rotate", "no"),
|
("video-rotate", "no"),
|
||||||
("keep-open", "no"),
|
("keep-open", "no"),
|
||||||
@@ -78,20 +152,21 @@ final class MpvPlayer {
|
|||||||
("demuxer-readahead-secs", "30"),
|
("demuxer-readahead-secs", "30"),
|
||||||
("demuxer-max-bytes", "50331648"),
|
("demuxer-max-bytes", "50331648"),
|
||||||
("subs-fallback", "yes"),
|
("subs-fallback", "yes"),
|
||||||
|
// AVAudioSession is this app's to manage, as audio focus is
|
||||||
|
// Android's.
|
||||||
|
("audio-exclusive", "no"),
|
||||||
] {
|
] {
|
||||||
mpv_set_option_string(mpv, name, value)
|
mpv_set_option_string(mpv, name, value)
|
||||||
}
|
}
|
||||||
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
|
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
|
||||||
mpv_terminate_destroy(mpv)
|
mpv_terminate_destroy(mpv)
|
||||||
failure = "libmpv did not initialize"
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE)
|
for (id, name, format) in Self.observed {
|
||||||
mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE)
|
mpv_observe_property(mpv, id, name, format)
|
||||||
mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG)
|
}
|
||||||
mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG)
|
// Errors mpv reports about the file itself, rather than about the
|
||||||
mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64)
|
// request that opened it. Without these a failed demux is silent.
|
||||||
mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64)
|
|
||||||
mpv_request_log_messages(mpv, "error")
|
mpv_request_log_messages(mpv, "error")
|
||||||
self.mpv = mpv
|
self.mpv = mpv
|
||||||
|
|
||||||
@@ -100,52 +175,184 @@ final class MpvPlayer {
|
|||||||
thread.start()
|
thread.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let observed: [(UInt64, String, mpv_format)] = [
|
||||||
|
(1, "time-pos", MPV_FORMAT_DOUBLE),
|
||||||
|
(2, "duration", MPV_FORMAT_DOUBLE),
|
||||||
|
(3, "pause", MPV_FORMAT_FLAG),
|
||||||
|
(4, "volume", MPV_FORMAT_DOUBLE),
|
||||||
|
(5, "mute", MPV_FORMAT_FLAG),
|
||||||
|
(6, "video-params/dw", MPV_FORMAT_INT64),
|
||||||
|
(7, "video-params/dh", MPV_FORMAT_INT64),
|
||||||
|
(8, "paused-for-cache", MPV_FORMAT_FLAG),
|
||||||
|
(9, "cache-buffering-state", MPV_FORMAT_INT64),
|
||||||
|
(10, "demuxer-cache-time", MPV_FORMAT_DOUBLE),
|
||||||
|
(11, "track-list/count", MPV_FORMAT_INT64),
|
||||||
|
(12, "chapter-list/count", MPV_FORMAT_INT64),
|
||||||
|
(13, "seeking", MPV_FORMAT_FLAG),
|
||||||
|
]
|
||||||
|
|
||||||
// MARK: Commands
|
// MARK: Commands
|
||||||
|
|
||||||
/// Load `pstr://<handle>`. On `false` nothing was issued, and the caller
|
/// Load `pstr://<handle>` as `media`. On `false` nothing was issued, and
|
||||||
/// still owns the handle's release.
|
/// the caller still owns the handle's release.
|
||||||
func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool {
|
func load(
|
||||||
|
media: String,
|
||||||
|
handle: UInt64,
|
||||||
|
start: Double,
|
||||||
|
audio: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
subtitles: Bool,
|
||||||
|
hardwareDecoding: Bool
|
||||||
|
) -> Bool {
|
||||||
guard mpv != nil else { return false }
|
guard mpv != nil else { return false }
|
||||||
|
// Per file, before `loadfile`: a file VideoToolbox decodes wrongly is
|
||||||
|
// fixed from the next episode without restarting the core.
|
||||||
|
setString("hwdec", hardwareDecoding ? "videotoolbox" : "no")
|
||||||
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
|
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
|
||||||
setString("alang", audio ?? "")
|
setString("alang", audio ?? "")
|
||||||
setString("slang", subtitle ?? "")
|
setString("slang", subtitle ?? "")
|
||||||
setString("sid", subtitles ? "auto" : "no")
|
setString("sid", subtitles ? "auto" : "no")
|
||||||
position = 0
|
// Retire the outgoing file's readings *before* the new one loads: mpv
|
||||||
|
// only republishes them once it has demuxed enough of the stream, and
|
||||||
|
// until then every reader would see the last episode's clock under the
|
||||||
|
// new episode's name. Volume, mute and pause are the core's, not the
|
||||||
|
// file's, and carry across.
|
||||||
|
position = start
|
||||||
duration = 0
|
duration = 0
|
||||||
|
endReason = nil
|
||||||
|
buffering = false
|
||||||
|
cachePercent = 0
|
||||||
|
seeking = false
|
||||||
|
cachedUntil = 0
|
||||||
|
videoWidth = 0
|
||||||
|
videoHeight = 0
|
||||||
tracks = []
|
tracks = []
|
||||||
chapters = []
|
chapters = []
|
||||||
failure = nil
|
problem = nil
|
||||||
|
self.media = nil
|
||||||
|
generation += 1
|
||||||
|
// Subtitles for the file being replaced will never be added.
|
||||||
|
pendingSubtitles.forEach { pstr_android_stream_release($0.0.handle) }
|
||||||
|
pendingSubtitles = []
|
||||||
|
pendingMedia = media
|
||||||
loading = true
|
loading = true
|
||||||
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
|
||||||
|
// Nothing was issued, so no START_FILE will arrive to clear this.
|
||||||
loading = false
|
loading = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPaused(_ value: Bool) { setFlag("pause", value) }
|
/// Add the subtitle files beside the file of load `generation`.
|
||||||
func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) }
|
///
|
||||||
func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) }
|
/// Held until mpv has that file loaded, since an external track belongs to
|
||||||
|
/// the file open at the time, and released instead if another file has
|
||||||
|
/// been loaded since. `files.preferred` is turned on only when the file
|
||||||
|
/// has no subtitle of its own playing; `subtitles` false turns none on.
|
||||||
|
func addSubtitles(generation: Int, files: SubtitleFilesRecord, subtitles: Bool) {
|
||||||
|
let marked = files.files.enumerated().map { index, file in
|
||||||
|
(file, subtitles && files.preferred == UInt32(index))
|
||||||
|
}
|
||||||
|
if generation != self.generation {
|
||||||
|
marked.forEach { pstr_android_stream_release($0.0.handle) }
|
||||||
|
} else if loadedGeneration == generation {
|
||||||
|
attach(marked)
|
||||||
|
} else {
|
||||||
|
pendingSubtitles += marked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sub-add` off the main thread: mpv reads the whole file before it
|
||||||
|
/// returns, through the stream protocol and so from the network.
|
||||||
|
private func attach(_ subtitles: [(SubtitleFileRecord, Bool)]) {
|
||||||
|
guard let mpv, !subtitles.isEmpty else { return }
|
||||||
|
let showing = getString("sid").map { $0 != "no" } ?? false
|
||||||
|
let core = UncheckedCore(mpv)
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
for (file, select) in subtitles {
|
||||||
|
var args = ["sub-add", "pstr://\(file.handle)", select && !showing ? "select" : "auto", file.title]
|
||||||
|
if let language = file.language { args.append(language) }
|
||||||
|
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
|
||||||
|
pointers.append(nil)
|
||||||
|
let status = mpv_command(core.pointer, &pointers)
|
||||||
|
pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) }
|
||||||
|
// On failure mpv may never have opened it, so nothing will
|
||||||
|
// close it. Releasing one it did open and close is a no-op.
|
||||||
|
if status < 0 { pstr_android_stream_release(file.handle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Written through rather than left to the observer: a tap that only shows
|
||||||
|
/// its effect on mpv's next property event reads as a button that missed.
|
||||||
|
func setPaused(_ value: Bool) {
|
||||||
|
setFlag("pause", value)
|
||||||
|
paused = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to seconds: Double) {
|
||||||
|
setDouble("time-pos", max(0, seconds))
|
||||||
|
seeking = true
|
||||||
|
}
|
||||||
|
|
||||||
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
|
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
|
||||||
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
|
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
|
||||||
func setMuted(_ value: Bool) { setFlag("mute", value) }
|
func setMuted(_ value: Bool) { setFlag("mute", value) }
|
||||||
|
|
||||||
func select(audio: Bool, track: MpvTrack?) {
|
/// A track, or none: `nil` turns subtitles off.
|
||||||
let property = audio ? "aid" : "sid"
|
func select(type: String, track: MpvTrack?) {
|
||||||
if let track { setString(property, String(track.id)) } else { setString(property, "no") }
|
setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Showing no picture while backgrounded keeps the audio playing and avoids
|
/// Showing no picture while backgrounded keeps the audio playing: iOS
|
||||||
/// the black frame MoltenVK otherwise leaves on return.
|
/// refuses GPU work from a background app, and would end the process for it.
|
||||||
func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") }
|
func setVideoEnabled(_ enabled: Bool) {
|
||||||
|
videoEnabled = enabled
|
||||||
|
setVideo([enabled ? "auto" : "no"])
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop and tear the core down. `cancel_fn` interrupts a read parked in a
|
/// The layer's drawable changed size: a rotation, a Split View resize.
|
||||||
/// block fetch, and the event thread destroys the core once it is down.
|
///
|
||||||
func shutdown() {
|
/// MPVKit's MoltenVK context reads `drawableSize` only when mpv builds its
|
||||||
|
/// output and never hears of a resize after that (mpvkit/MPVKit#3), so mpv
|
||||||
|
/// went on drawing a frame of the old size into the corner of the new
|
||||||
|
/// drawable. Turning the picture off and on builds the output again at the
|
||||||
|
/// new size — the same round trip the background makes, at the cost of a
|
||||||
|
/// refresh seek. Once the size has settled, so a Split View drag rebuilds
|
||||||
|
/// it once rather than per step.
|
||||||
|
func outputResized() {
|
||||||
|
refitTask?.cancel()
|
||||||
|
refitTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
let size = layer.drawableSize
|
||||||
|
// Only an output that exists and is the wrong size: nothing to fit
|
||||||
|
// while a file loads, and the background's "no" stays.
|
||||||
|
guard videoEnabled, videoWidth > 0, outputSize != .zero, size != outputSize,
|
||||||
|
size.width > 1, size.height > 1 else { return }
|
||||||
|
outputSize = size
|
||||||
|
setVideo(["no", "auto"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setVideo(_ values: [String]) {
|
||||||
guard let mpv else { return }
|
guard let mpv else { return }
|
||||||
self.mpv = nil
|
let core = UncheckedCore(mpv)
|
||||||
mpv_command_string(mpv, "quit")
|
videoQueue.async {
|
||||||
|
for value in values { mpv_set_property_string(core.pointer, "vid", value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the open file. The stream behind it is released by mpv closing it,
|
||||||
|
/// which this causes; releasing it from here would race that close.
|
||||||
|
func stop() {
|
||||||
|
_ = command(["stop"])
|
||||||
|
media = nil
|
||||||
|
pendingMedia = nil
|
||||||
|
chapters = []
|
||||||
|
tracks = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Events
|
// MARK: Events
|
||||||
@@ -153,10 +360,14 @@ final class MpvPlayer {
|
|||||||
private enum Event {
|
private enum Event {
|
||||||
case double(UInt64, Double)
|
case double(UInt64, Double)
|
||||||
case flag(UInt64, Bool)
|
case flag(UInt64, Bool)
|
||||||
case count(UInt64)
|
case int(UInt64, Int64)
|
||||||
|
case unavailable(UInt64)
|
||||||
|
case started
|
||||||
case loaded
|
case loaded
|
||||||
case ended(Int32)
|
case ended(Int64)
|
||||||
case log(String)
|
case restarted
|
||||||
|
case reconfigured
|
||||||
|
case log(prefix: String, text: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs until the core shuts down, then destroys it. Nothing else calls
|
/// Runs until the core shuts down, then destroys it. Nothing else calls
|
||||||
@@ -170,24 +381,36 @@ final class MpvPlayer {
|
|||||||
mpv_terminate_destroy(mpv)
|
mpv_terminate_destroy(mpv)
|
||||||
return
|
return
|
||||||
case MPV_EVENT_PROPERTY_CHANGE:
|
case MPV_EVENT_PROPERTY_CHANGE:
|
||||||
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee,
|
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee else { break }
|
||||||
let data = property.data else { break }
|
guard let data = property.data else {
|
||||||
|
decoded = .unavailable(event.reply_userdata)
|
||||||
|
break
|
||||||
|
}
|
||||||
switch property.format {
|
switch property.format {
|
||||||
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
|
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
|
||||||
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
|
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
|
||||||
case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata)
|
case MPV_FORMAT_INT64: decoded = .int(event.reply_userdata, data.load(as: Int64.self))
|
||||||
default: break
|
default: decoded = .unavailable(event.reply_userdata)
|
||||||
}
|
}
|
||||||
|
case MPV_EVENT_START_FILE:
|
||||||
|
decoded = .started
|
||||||
case MPV_EVENT_FILE_LOADED:
|
case MPV_EVENT_FILE_LOADED:
|
||||||
decoded = .loaded
|
decoded = .loaded
|
||||||
|
case MPV_EVENT_PLAYBACK_RESTART:
|
||||||
|
decoded = .restarted
|
||||||
|
case MPV_EVENT_VIDEO_RECONFIG:
|
||||||
|
decoded = .reconfigured
|
||||||
case MPV_EVENT_END_FILE:
|
case MPV_EVENT_END_FILE:
|
||||||
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
|
||||||
decoded = .ended(end.reason.rawValue)
|
decoded = .ended(Int64(end.reason.rawValue))
|
||||||
}
|
}
|
||||||
case MPV_EVENT_LOG_MESSAGE:
|
case MPV_EVENT_LOG_MESSAGE:
|
||||||
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
|
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
|
||||||
let text = message.text {
|
let text = message.text {
|
||||||
decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines))
|
decoded = .log(
|
||||||
|
prefix: message.prefix.map { String(cString: $0) } ?? "",
|
||||||
|
text: String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -202,28 +425,87 @@ final class MpvPlayer {
|
|||||||
switch event {
|
switch event {
|
||||||
case let .double(id, value):
|
case let .double(id, value):
|
||||||
if loading { return }
|
if loading { return }
|
||||||
if id == 1 { position = value } else if id == 2 { duration = value }
|
switch id {
|
||||||
|
case 1:
|
||||||
|
position = value
|
||||||
|
onTick?()
|
||||||
|
case 2:
|
||||||
|
duration = value
|
||||||
|
onStateChanged?()
|
||||||
|
case 4: volume = value
|
||||||
|
case 10: cachedUntil = value
|
||||||
|
default: break
|
||||||
|
}
|
||||||
case let .flag(id, value):
|
case let .flag(id, value):
|
||||||
if id == 3 { paused = value } else if id == 4 { buffering = value }
|
switch id {
|
||||||
case let .count(id):
|
case 3:
|
||||||
|
paused = value
|
||||||
|
onStateChanged?()
|
||||||
|
case 5: muted = 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
|
||||||
|
}
|
||||||
|
case let .int(id, value):
|
||||||
if loading { return }
|
if loading { return }
|
||||||
if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() }
|
switch id {
|
||||||
|
case 6: videoWidth = Double(value)
|
||||||
|
case 7: videoHeight = Double(value)
|
||||||
|
case 9: cachePercent = Double(value)
|
||||||
|
case 11: refreshTracks()
|
||||||
|
case 12: refreshChapters()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case let .unavailable(id):
|
||||||
|
if loading { return }
|
||||||
|
if id == 6 || id == 7 { videoWidth = 0; videoHeight = 0 }
|
||||||
|
case .started:
|
||||||
|
// From here on, what mpv reports is the file just loaded.
|
||||||
|
if loading {
|
||||||
|
loading = false
|
||||||
|
media = pendingMedia
|
||||||
|
}
|
||||||
case .loaded:
|
case .loaded:
|
||||||
loading = false
|
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
refreshChapters()
|
refreshChapters()
|
||||||
|
// Mid-load, this is the outgoing file finishing its load.
|
||||||
|
if !loading {
|
||||||
|
loadedGeneration = generation
|
||||||
|
let ready = pendingSubtitles
|
||||||
|
pendingSubtitles = []
|
||||||
|
attach(ready)
|
||||||
|
onLoaded?()
|
||||||
|
}
|
||||||
|
case .restarted:
|
||||||
|
seeking = false
|
||||||
|
case .reconfigured:
|
||||||
|
// mpv has just read the drawable to size its output by.
|
||||||
|
outputSize = layer.drawableSize
|
||||||
case let .ended(reason):
|
case let .ended(reason):
|
||||||
// The outgoing file's END_FILE arrives after the next load began.
|
// The outgoing file's END_FILE arrives after the next load began.
|
||||||
if loading { return }
|
if loading { return }
|
||||||
switch reason {
|
let ended: EndReason = switch reason {
|
||||||
case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof)
|
case Int64(MPV_END_FILE_REASON_EOF.rawValue): .eof
|
||||||
case MPV_END_FILE_REASON_ERROR.rawValue:
|
case Int64(MPV_END_FILE_REASON_STOP.rawValue): .stopped
|
||||||
failure = failure ?? "The file could not be played"
|
case Int64(MPV_END_FILE_REASON_QUIT.rawValue): .quit
|
||||||
onEnded?(.failed)
|
case Int64(MPV_END_FILE_REASON_ERROR.rawValue): .failed
|
||||||
default: onEnded?(.stopped)
|
default: .other
|
||||||
}
|
}
|
||||||
case let .log(text):
|
endReason = ended
|
||||||
if !text.isEmpty { failure = text }
|
if ended == .failed { problem = problem ?? "This episode could not be played." }
|
||||||
|
if let media { onEnded?(ended, media) }
|
||||||
|
case let .log(prefix, text):
|
||||||
|
// The outgoing file's complaints are not the new one's.
|
||||||
|
if loading { return }
|
||||||
|
// The first complaint per file, and not ffmpeg's: its decoders log
|
||||||
|
// recoverable glitches at error level all through a healthy file.
|
||||||
|
if text.isEmpty || prefix.hasPrefix("ffmpeg") || problem != nil { return }
|
||||||
|
problem = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +518,9 @@ final class MpvPlayer {
|
|||||||
type: getString(base + "type") ?? "",
|
type: getString(base + "type") ?? "",
|
||||||
language: getString(base + "lang") ?? "",
|
language: getString(base + "lang") ?? "",
|
||||||
title: getString(base + "title") ?? "",
|
title: getString(base + "title") ?? "",
|
||||||
selected: getString(base + "selected") == "yes"
|
selected: getString(base + "selected") == "yes",
|
||||||
|
isDefault: getString(base + "default") == "yes",
|
||||||
|
forced: getString(base + "forced") == "yes"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,3 +580,11 @@ final class MpvPlayer {
|
|||||||
return String(cString: raw)
|
return String(cString: raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mpv's handle, for a command issued off the main thread. libmpv's client API
|
||||||
|
/// is thread-safe, and the core is only destroyed when it shuts down, which
|
||||||
|
/// nothing in the app asks it to.
|
||||||
|
private struct UncheckedCore: @unchecked Sendable {
|
||||||
|
let pointer: OpaquePointer
|
||||||
|
init(_ pointer: OpaquePointer) { self.pointer = pointer }
|
||||||
|
}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import MediaPlayer
|
||||||
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// How long the "up next" card counts down before it loads the next episode.
|
||||||
|
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)" }
|
||||||
|
|
||||||
|
/// The open episode's stream, from Rust: opened, handed to mpv, given back.
|
||||||
|
///
|
||||||
|
/// The native handle belongs to mpv once a load succeeds — `close_fn` in the
|
||||||
|
/// stream protocol releases it after mpv's last read. Only a load that never
|
||||||
|
/// happened leaves it here to release.
|
||||||
|
private final class StreamSession {
|
||||||
|
let episode: EpisodeRecord
|
||||||
|
private(set) var handle: UInt64?
|
||||||
|
private(set) var size: UInt64 = 0
|
||||||
|
|
||||||
|
init(episode: EpisodeRecord) {
|
||||||
|
self.episode = episode
|
||||||
|
}
|
||||||
|
|
||||||
|
func open() async throws {
|
||||||
|
let stream = try await NativeRuntime.engine().openStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
|
||||||
|
size = stream.size()
|
||||||
|
handle = stream.nativeHandle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player now owns the published handle until stop, end or close.
|
||||||
|
func transferToPlayer() {
|
||||||
|
handle = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give the episode's stream back to Rust, from a task of its own: the one
|
||||||
|
/// moment this has to run is when the player moves on, and nothing that
|
||||||
|
/// goes away with it may cancel it.
|
||||||
|
func close() {
|
||||||
|
if let handle { pstr_android_stream_release(handle) }
|
||||||
|
handle = nil
|
||||||
|
let episode = episode
|
||||||
|
Task.detached {
|
||||||
|
try? await NativeRuntime.engine().releaseStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the player has open and everything that walks it: the stream per
|
||||||
|
/// episode, the resume position, autoplay, auto-skip, the lock screen and the
|
||||||
|
/// audio session.
|
||||||
|
///
|
||||||
|
/// Android splits this between `PlayerScreen`, `NativeMpvHost` and
|
||||||
|
/// `PlaybackService`. Here it is one object that outlives the player's view,
|
||||||
|
/// because the episode keeps playing while the view is minimised or the app
|
||||||
|
/// is in the background.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class PlayerHost {
|
||||||
|
static let shared = PlayerHost()
|
||||||
|
|
||||||
|
/// The core, once something has been played. Nil if libmpv would not start.
|
||||||
|
private(set) var player: MpvPlayer?
|
||||||
|
private(set) var unavailable = false
|
||||||
|
private(set) var title: TitleRecord?
|
||||||
|
private(set) var episodes: [EpisodeRecord] = []
|
||||||
|
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 {
|
||||||
|
// 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
|
||||||
|
private(set) var autoplay = true
|
||||||
|
/// The rate playing, so the speed list opens on it.
|
||||||
|
var speed = 1.0
|
||||||
|
|
||||||
|
/// Persist where an episode was left: position, duration, watched.
|
||||||
|
@ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)?
|
||||||
|
/// The player closed: where it stopped is what another device wants next.
|
||||||
|
@ObservationIgnored var onClosed: (() -> Void)?
|
||||||
|
|
||||||
|
@ObservationIgnored private var session: StreamSession?
|
||||||
|
@ObservationIgnored private var loadTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var saveTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var plannedFor: (Double, Int)?
|
||||||
|
/// 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?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
observeAudioSession()
|
||||||
|
configureRemoteCommands()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/// Open a title's episodes at `index`, or move to it if the title is open.
|
||||||
|
func play(_ title: TitleRecord, at index: Int) {
|
||||||
|
let episodes = title.playlist
|
||||||
|
guard !episodes.isEmpty else { return }
|
||||||
|
let target = min(max(index, 0), episodes.count - 1)
|
||||||
|
// The same episode, still open: only bring it back on screen. One that
|
||||||
|
// has ended has nothing loaded, and is played again.
|
||||||
|
if self.title?.key == title.key, mediaKey(episodes[target]) == key, player?.endReason == nil {
|
||||||
|
self.title = title
|
||||||
|
self.episodes = episodes
|
||||||
|
minimized = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
self.title = title
|
||||||
|
self.episodes = episodes
|
||||||
|
self.index = target
|
||||||
|
minimized = false
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the open title's records current after the library reloads, so the
|
||||||
|
/// episode list shows what was just watched.
|
||||||
|
func update(titles: [TitleRecord]) {
|
||||||
|
guard let key = title?.key, let fresh = titles.first(where: { $0.key == key }) else { return }
|
||||||
|
let current = self.key
|
||||||
|
title = fresh
|
||||||
|
episodes = fresh.playlist
|
||||||
|
if let current, let moved = episodes.firstIndex(where: { mediaKey($0) == current }) { index = moved }
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance() {
|
||||||
|
guard next != nil else { return }
|
||||||
|
move(to: index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retreat() {
|
||||||
|
guard index > 0 else { return }
|
||||||
|
move(to: index - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jump(to target: Int) {
|
||||||
|
guard target != index, episodes.indices.contains(target) else { return }
|
||||||
|
move(to: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(to target: Int) {
|
||||||
|
save()
|
||||||
|
index = target
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback and close the player. The mini transport's ✕ and the
|
||||||
|
/// player's own are the one place it is unambiguous.
|
||||||
|
func close() {
|
||||||
|
save()
|
||||||
|
loadTask?.cancel()
|
||||||
|
saveTask?.cancel()
|
||||||
|
player?.stop()
|
||||||
|
session?.close()
|
||||||
|
session = nil
|
||||||
|
title = nil
|
||||||
|
episodes = []
|
||||||
|
index = 0
|
||||||
|
minimized = false
|
||||||
|
playbackError = nil
|
||||||
|
opening = false
|
||||||
|
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||||
|
OrientationLock.set(false)
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
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
|
||||||
|
chapters = ChapterPlan(entries: [], creditsStart: nil)
|
||||||
|
plannedFor = nil
|
||||||
|
skipped = []
|
||||||
|
let session = StreamSession(episode: episode)
|
||||||
|
self.session = session
|
||||||
|
let key = mediaKey(episode)
|
||||||
|
let titleKey = title.key
|
||||||
|
publishNowPlaying()
|
||||||
|
if player == nil, !unavailable {
|
||||||
|
player = MpvPlayer.create()
|
||||||
|
unavailable = player == nil
|
||||||
|
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
|
||||||
|
player?.onTick = { [weak self] in self?.tick() }
|
||||||
|
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
|
||||||
|
player?.onLoaded = { [weak self] in self?.preferFullSubtitles() }
|
||||||
|
}
|
||||||
|
guard let player else {
|
||||||
|
playbackError = "Playback is unavailable: libmpv did not start."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
opening = true
|
||||||
|
loadTask = Task {
|
||||||
|
do {
|
||||||
|
try await session.open()
|
||||||
|
let startup = try await Task.detached {
|
||||||
|
let engine = try NativeRuntime.blockingEngine()
|
||||||
|
// A choice made on this title wins; without one, the choice
|
||||||
|
// the viewer made anywhere else does — the desktop client's
|
||||||
|
// fallback chain.
|
||||||
|
let show = try engine.titleTrackPreferences(titleKey: titleKey)
|
||||||
|
let global = try engine.playbackPrefs()
|
||||||
|
let watch = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
|
||||||
|
return (show, global, watch)
|
||||||
|
}.value
|
||||||
|
if Task.isCancelled || self.session !== session {
|
||||||
|
abandon(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let (show, global, watch) = startup
|
||||||
|
guard let handle = session.handle, session.size > 0 else { throw PlaybackFailure("Cannot play an empty stream") }
|
||||||
|
autoSkip = global.autoSkip
|
||||||
|
autoplay = global.autoplayNext
|
||||||
|
speed = global.speed
|
||||||
|
let loaded = player.load(
|
||||||
|
media: key,
|
||||||
|
handle: handle,
|
||||||
|
// A finished episode restarts rather than resuming three
|
||||||
|
// seconds from its own credits.
|
||||||
|
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)
|
||||||
|
player.setMuted(global.muted)
|
||||||
|
player.setSpeed(global.speed)
|
||||||
|
player.setPaused(false)
|
||||||
|
// While mpv opens the episode; the player holds them until it has.
|
||||||
|
let generation = player.generation
|
||||||
|
let subtitleLanguage = show?.subtitleLanguage ?? global.subtitleLanguage
|
||||||
|
let subtitles = show?.subtitles ?? global.subtitles
|
||||||
|
Task {
|
||||||
|
guard let files = try? await NativeRuntime.engine().openSubtitles(
|
||||||
|
shareId: episode.shareId, linkId: episode.linkId, language: subtitleLanguage
|
||||||
|
) else { return }
|
||||||
|
player.addSubtitles(generation: generation, files: files, subtitles: subtitles)
|
||||||
|
}
|
||||||
|
startSaving()
|
||||||
|
} catch {
|
||||||
|
if Task.isCancelled || self.session !== session {
|
||||||
|
abandon(session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opening = false
|
||||||
|
playbackError = errorMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session the player moved past while it was still opening. Its
|
||||||
|
/// `close()` already ran, before there was a handle to release, so the one
|
||||||
|
/// it has published since is released here — and only that, since the
|
||||||
|
/// stream itself was already given back.
|
||||||
|
private func abandon(_ session: StreamSession) {
|
||||||
|
if let handle = session.handle {
|
||||||
|
pstr_android_stream_release(handle)
|
||||||
|
session.transferToPlayer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PlaybackFailure: LocalizedError {
|
||||||
|
let errorDescription: String?
|
||||||
|
init(_ message: String) { errorDescription = message }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
/// Resume position, written only from readings that carry this episode's
|
||||||
|
/// name: an unattributed reading is the previous one's clock.
|
||||||
|
func save() {
|
||||||
|
guard let player, let episode, player.media == mediaKey(episode), player.duration > 0 else { return }
|
||||||
|
onSaveProgress?(
|
||||||
|
episode,
|
||||||
|
min(max(player.position, 0), player.duration),
|
||||||
|
player.duration,
|
||||||
|
// Watched means played to the end. An episode that was stopped, or
|
||||||
|
// that mpv could not play at all, also "ended" — recording either
|
||||||
|
// as watched retires an episode nobody saw.
|
||||||
|
player.endReason == .eof || player.position >= player.duration * watchedFraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSaving() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
saveTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(5))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reacting to the player
|
||||||
|
|
||||||
|
private func ended(_ reason: EndReason, _ media: String) {
|
||||||
|
guard media == key else { return }
|
||||||
|
save()
|
||||||
|
// Autoplay fires on a clean end of file only — never on a failure.
|
||||||
|
if reason == .eof, autoplay, !stay, next != nil {
|
||||||
|
advance()
|
||||||
|
} else if reason == .failed {
|
||||||
|
playbackError = player?.problem ?? "This episode could not be played."
|
||||||
|
}
|
||||||
|
publishNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick() {
|
||||||
|
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
|
||||||
|
/// duration that has only just arrived. Both change the answer — the length
|
||||||
|
/// of a chapter is what settles whether `Intro` is an opening or ten minutes
|
||||||
|
/// of story — and neither changes again after that.
|
||||||
|
private func replanChapters(_ player: MpvPlayer) {
|
||||||
|
let wanted = (player.duration, player.generation)
|
||||||
|
if let plannedFor, plannedFor == wanted, !(chapters.entries.isEmpty && !player.chapters.isEmpty) { return }
|
||||||
|
plannedFor = wanted
|
||||||
|
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play the dialogue rather than the signs and songs mpv picked by
|
||||||
|
/// language (`pstr_core::subtitles`). Not remembered: it is the choice mpv
|
||||||
|
/// should have made, not the viewer's.
|
||||||
|
private func preferFullSubtitles() {
|
||||||
|
guard let player, playingOpenEpisode else { return }
|
||||||
|
let subtitles = player.tracks.filter { $0.type == "sub" }.map {
|
||||||
|
SubtitleTrackRecord(id: $0.id, language: $0.language, title: $0.title, default: $0.isDefault, forced: $0.forced, selected: $0.selected)
|
||||||
|
}
|
||||||
|
guard let id = fullSubtitlesInstead(tracks: subtitles), let track = player.tracks.first(where: { $0.type == "sub" && $0.id == id }) else { return }
|
||||||
|
player.select(type: "sub", track: track)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The skip button's offer at the current position, if any.
|
||||||
|
var offer: SkipOffer? {
|
||||||
|
guard let player, playingOpenEpisode else { return nil }
|
||||||
|
return skipOffer(plan: chapters, position: player.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
func setPaused(_ paused: Bool) {
|
||||||
|
player?.setPaused(paused)
|
||||||
|
if !paused { try? AVAudioSession.sharedInstance().setActive(true) }
|
||||||
|
publishNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to seconds: Double) {
|
||||||
|
player?.seek(to: seconds)
|
||||||
|
updateElapsed(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a volume or a mute, leaving whichever was not given as it was.
|
||||||
|
/// Written when the thumb is released rather than while it moves.
|
||||||
|
func rememberVolume(volume: Double? = nil, muted: Bool? = nil) {
|
||||||
|
Task.detached {
|
||||||
|
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
|
||||||
|
prefs.volume = volume ?? prefs.volume
|
||||||
|
prefs.muted = muted ?? prefs.muted
|
||||||
|
try? engine.setPlaybackPrefs(prefs: prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a playback rate, so the next episode starts at it.
|
||||||
|
func setSpeed(_ rate: Double) {
|
||||||
|
speed = rate
|
||||||
|
player?.setSpeed(rate)
|
||||||
|
publishNowPlaying()
|
||||||
|
Task.detached {
|
||||||
|
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
|
||||||
|
prefs.speed = rate
|
||||||
|
try? engine.setPlaybackPrefs(prefs: prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A track for this title. The choice is the title's from now on.
|
||||||
|
func select(type: String, track: MpvTrack?) {
|
||||||
|
player?.select(type: type, track: track)
|
||||||
|
guard let titleKey = title?.key else { return }
|
||||||
|
Task.detached {
|
||||||
|
guard let engine = try? NativeRuntime.blockingEngine(), let global = try? engine.playbackPrefs() else { return }
|
||||||
|
// No per-title choice yet: start from what plays everywhere else,
|
||||||
|
// so choosing an audio track does not silently drop the subtitle
|
||||||
|
// preference this episode started with.
|
||||||
|
var preferences = (try? engine.titleTrackPreferences(titleKey: titleKey)) ?? TrackPreferencesRecord(
|
||||||
|
audioLanguage: global.audioLanguage,
|
||||||
|
subtitleLanguage: global.subtitleLanguage,
|
||||||
|
subtitles: global.subtitles
|
||||||
|
)
|
||||||
|
let language = track.flatMap { $0.language.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0.language }
|
||||||
|
if type == "audio" {
|
||||||
|
preferences.audioLanguage = language
|
||||||
|
} else {
|
||||||
|
// Turning subtitles off keeps the language it was off *from*, so
|
||||||
|
// turning them back on does not have to be told it again.
|
||||||
|
preferences.subtitleLanguage = language ?? preferences.subtitleLanguage
|
||||||
|
preferences.subtitles = track != nil
|
||||||
|
}
|
||||||
|
try? engine.setTitleTrackPreferences(titleKey: titleKey, preferences: preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissProblem() {
|
||||||
|
playbackError = nil
|
||||||
|
player?.problem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
/// Leaving the screen: the picture goes, the sound stays if the viewer
|
||||||
|
/// asked for background audio, and the position is saved either way.
|
||||||
|
func enteredBackground() {
|
||||||
|
save()
|
||||||
|
guard isOpen, let player else { return }
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enteredForeground() {
|
||||||
|
player?.setVideoEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock screen
|
||||||
|
|
||||||
|
/// What the lock screen and Control Center show: the episode, its show and
|
||||||
|
/// its art — never just the app.
|
||||||
|
private func publishNowPlaying() {
|
||||||
|
guard let title, let episode else {
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let center = MPNowPlayingInfoCenter.default()
|
||||||
|
var info = center.nowPlayingInfo ?? [:]
|
||||||
|
info[MPMediaItemPropertyTitle] = episode.label
|
||||||
|
info[MPMediaItemPropertyArtist] = title.displayName
|
||||||
|
info[MPMediaItemPropertyAlbumTitle] = title.displayName
|
||||||
|
info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||||
|
info[MPMediaItemPropertyPlaybackDuration] = player?.duration ?? 0
|
||||||
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player?.position ?? 0
|
||||||
|
// 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
|
||||||
|
updateIdleTimer()
|
||||||
|
|
||||||
|
let artKey = mediaKey(episode)
|
||||||
|
if artworkFor != artKey {
|
||||||
|
artworkFor = artKey
|
||||||
|
info[MPMediaItemPropertyArtwork] = nil
|
||||||
|
center.nowPlayingInfo = info
|
||||||
|
let url = title.posterUrl ?? title.backdropUrl
|
||||||
|
let source = episode.thumbnailSource
|
||||||
|
Task {
|
||||||
|
guard let image = await ArtworkLoader.shared.artwork(url: url, fallback: source), artworkFor == artKey else { return }
|
||||||
|
var current = center.nowPlayingInfo ?? [:]
|
||||||
|
current[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
|
center.nowPlayingInfo = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 }
|
||||||
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seconds
|
||||||
|
center.nowPlayingInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureRemoteCommands() {
|
||||||
|
let commands = MPRemoteCommandCenter.shared()
|
||||||
|
commands.playCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.setPaused(false) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.pauseCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.setPaused(true) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.setPaused(!(self?.player?.paused ?? true)) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
|
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||||
|
MainActor.assumeIsolated { self?.seek(to: event.positionTime) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.nextTrackCommand.addTarget { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.advance() }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
commands.previousTrackCommand.addTarget { [weak self] _ in
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call or an alarm pauses, and its end resumes only what this paused —
|
||||||
|
/// Android's transient focus loss. Headphones coming out pause, as
|
||||||
|
/// `BECOMING_NOISY` does there.
|
||||||
|
private func observeAudioSession() {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: raw)
|
||||||
|
else { return }
|
||||||
|
let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt).map(AVAudioSession.InterruptionOptions.init)
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self, let player = self.player, self.isOpen else { return }
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
if !player.paused {
|
||||||
|
self.pausedByInterruption = true
|
||||||
|
self.setPaused(true)
|
||||||
|
}
|
||||||
|
case .ended:
|
||||||
|
if self.pausedByInterruption, options?.contains(.shouldResume) == true { self.setPaused(false) }
|
||||||
|
self.pausedByInterruption = false
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] note in
|
||||||
|
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable
|
||||||
|
else { return }
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
guard let self, self.isOpen else { return }
|
||||||
|
self.setPaused(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// How long the controls stay up after a tap.
|
||||||
|
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 = 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.
|
||||||
|
private let speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||||
|
|
||||||
|
/// The player, over the whole window, notch included.
|
||||||
|
///
|
||||||
|
/// The episode itself lives in `PlayerHost`, so this view can go — minimised to
|
||||||
|
/// the mini transport, or with the app in the background — while it plays on.
|
||||||
|
struct PlayerView: View {
|
||||||
|
let host: PlayerHost
|
||||||
|
let player: MpvPlayer
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
@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) }
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if showControls {
|
||||||
|
PlayerControls(
|
||||||
|
host: host,
|
||||||
|
player: player,
|
||||||
|
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
|
||||||
|
roomForAll: geometry.size.width >= inlineControlsMinWidth,
|
||||||
|
landscapeLocked: landscapeLocked,
|
||||||
|
onRotate: {
|
||||||
|
rotationChosen = true
|
||||||
|
landscapeLocked = OrientationLock.toggle()
|
||||||
|
},
|
||||||
|
onSheet: { sheet = $0 },
|
||||||
|
onInteract: { setControls(true) }
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
|
Spacer()
|
||||||
|
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 && !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)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.bottom, showControls ? 132 : 24)
|
||||||
|
|
||||||
|
// Only while nothing else explains the still picture: an error
|
||||||
|
// is the more useful thing to say.
|
||||||
|
if (host.opening || player.stalled) && host.playbackError == nil {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.large).tint(scheme.primary)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.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.
|
||||||
|
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)
|
||||||
|
.background(scheme.errorContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.padding(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: showControls)
|
||||||
|
.statusBarHidden(!showControls)
|
||||||
|
.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)
|
||||||
|
case .episodes: EpisodeSheet(episodes: host.episodes, playing: host.index, onSelect: host.jump)
|
||||||
|
case .chapters: ChapterSheet(chapters: host.chapters.entries, position: player.position) { host.seek(to: $0.start) }
|
||||||
|
case let .tracks(type):
|
||||||
|
TrackSheet(type: type, tracks: player.tracks.filter { $0.type == type }) { host.select(type: type, track: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topBar: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Two ways out, because they mean different things: this leaves the
|
||||||
|
// video and keeps the episode playing, ✕ ends it.
|
||||||
|
IconButton("chevron.down", "Leave the video", tint: .white) {
|
||||||
|
// The rest of the app is not a landscape app.
|
||||||
|
landscapeLocked = false
|
||||||
|
OrientationLock.set(false)
|
||||||
|
host.minimized = true
|
||||||
|
}
|
||||||
|
// The episode leads and the show follows it: which episode is the
|
||||||
|
// thing a viewer who has just woken the controls is checking.
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(host.episode?.label ?? "").textStyle(.titleMedium).foregroundStyle(.white).lineLimit(1)
|
||||||
|
Text([host.title?.displayName, host.episode?.detail].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " • "))
|
||||||
|
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.7)).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
IconButton("xmark", "Stop playback", tint: .white, action: host.close)
|
||||||
|
if player.duration > 0, player.duration - player.position >= 1 {
|
||||||
|
Text("\(clock(player.duration - player.position)) left")
|
||||||
|
.textStyle(.labelMedium).foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.leading, 12).padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show or hide the controls; shown and playing, they hide themselves.
|
||||||
|
private func setControls(_ shown: Bool) {
|
||||||
|
showControls = shown
|
||||||
|
hideTask?.cancel()
|
||||||
|
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 {
|
||||||
|
case speed, episodes, chapters
|
||||||
|
case tracks(String)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .speed: "speed"
|
||||||
|
case .episodes: "episodes"
|
||||||
|
case .chapters: "chapters"
|
||||||
|
case let .tracks(type): "tracks-\(type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the transport offers, in two rows: the seek bar on its own,
|
||||||
|
/// then the transport cluster on the left, where a thumb already is, and the
|
||||||
|
/// pickers on the right.
|
||||||
|
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
|
||||||
|
let onInteract: () -> Void
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
// Where the thumb is while a finger is on it: a seek per drag step is a
|
||||||
|
// block fetch per drag step, and the thumb would snap back under the
|
||||||
|
// finger between readings.
|
||||||
|
@State private var scrubbing: Double?
|
||||||
|
@State private var dragVolume: Double?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let duration = max(player.duration, 1)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(clock(scrubbing ?? player.position)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||||||
|
AccentTrack(
|
||||||
|
value: Binding(
|
||||||
|
get: { scrubbing ?? min(max(player.position, 0), duration) },
|
||||||
|
set: { scrubbing = $0 }
|
||||||
|
),
|
||||||
|
range: 0 ... duration,
|
||||||
|
buffered: player.duration > 0 ? player.cachedUntil / player.duration : 0,
|
||||||
|
ticks: player.duration > 0 && host.chapters.entries.count > 1
|
||||||
|
? host.chapters.entries.dropFirst().map { $0.start / player.duration } : [],
|
||||||
|
onEditing: { editing in
|
||||||
|
onInteract()
|
||||||
|
if !editing, let target = scrubbing {
|
||||||
|
host.seek(to: target)
|
||||||
|
scrubbing = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(clock(player.duration)).textStyle(.labelMedium).foregroundStyle(.white).monospacedDigit()
|
||||||
|
}
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
CompactIcon("backward.end.fill", "Previous episode", enabled: host.index > 0, action: host.retreat)
|
||||||
|
CompactIcon("gobackward.10", "Back ten seconds") { host.seek(to: player.position - 10) }
|
||||||
|
Button { host.setPaused(!player.paused); onInteract() } label: {
|
||||||
|
Image(systemName: player.paused ? "play.fill" : "pause.fill")
|
||||||
|
.font(.system(size: 30))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(player.paused ? "Play" : "Pause")
|
||||||
|
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 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())
|
||||||
|
}
|
||||||
|
.accessibilityLabel("More")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
var enabled = true
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
init(_ icon: String, _ label: String, enabled: Bool = true, action: @escaping () -> Void) {
|
||||||
|
self.icon = icon
|
||||||
|
self.label = label
|
||||||
|
self.enabled = enabled
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
|
||||||
|
// 44pt: the smallest target Apple's guidelines allow.
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(PressableStyle(circle: true, wash: .white))
|
||||||
|
.disabled(!enabled)
|
||||||
|
.accessibilityLabel(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The countdown into the next episode, shown once the credits run starts.
|
||||||
|
///
|
||||||
|
/// "Watch till the end" holds for the rest of the file: someone who wants the
|
||||||
|
/// post-credits scene should have to say so once, not once every ten seconds.
|
||||||
|
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
|
||||||
|
@State private var heldFor: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let showing = visible && next != nil && !(held && heldFor == next?.linkId)
|
||||||
|
Group {
|
||||||
|
if showing, let upNext = next {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// The next episode's still, so the card says which
|
||||||
|
// episode without being read; the ring over it is both
|
||||||
|
// the countdown and the button that skips it.
|
||||||
|
Button(action: onPlayNow) {
|
||||||
|
RemoteArtwork(upNext.stillUrl, upNext.label, fallback: upNext.thumbnailSource, labelled: false)
|
||||||
|
.frame(width: 112)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(.black.opacity(0.5))
|
||||||
|
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
||||||
|
Circle()
|
||||||
|
.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: secondsLeft ?? remaining)
|
||||||
|
Image(systemName: "play.fill").foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressableStyle(wash: .white))
|
||||||
|
.accessibilityLabel("Play now")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
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(secondsLeft == nil ? "Watch till the end" : "Cancel") {
|
||||||
|
held = true
|
||||||
|
heldFor = upNext.linkId
|
||||||
|
if secondsLeft != nil { onStay() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.quiet)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.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))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
onPlayNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet("Playback speed") {
|
||||||
|
ForEach(speeds, id: \.self) { rate in
|
||||||
|
Button {
|
||||||
|
onSelect(rate)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text((abs(rate - current) < 0.001 ? "✓ " : "") + (rate == 1 ? "Normal" : "\(rate.formatted())×"))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChapterSheet: View {
|
||||||
|
let chapters: [ChapterEntry]
|
||||||
|
let position: Double
|
||||||
|
let onSelect: (ChapterEntry) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let current = chapters.lastIndex { position + 0.001 >= $0.start }
|
||||||
|
DialogSheet("Chapters") {
|
||||||
|
ForEach(Array(chapters.enumerated()), id: \.element.index) { position, chapter in
|
||||||
|
Button {
|
||||||
|
onSelect(chapter)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text((position == current ? "✓ " : "") + "\(clock(chapter.start)) \(chapter.label)")
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TrackSheet: View {
|
||||||
|
let type: String
|
||||||
|
let tracks: [MpvTrack]
|
||||||
|
let onSelect: (MpvTrack?) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DialogSheet(type == "audio" ? "Audio track" : "Subtitle track") {
|
||||||
|
if type == "sub" {
|
||||||
|
Button {
|
||||||
|
onSelect(nil)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text(tracks.contains(where: \.selected) ? "Off" : "✓ Off").frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
ForEach(tracks) { track in
|
||||||
|
Button {
|
||||||
|
onSelect(track)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text(track.selected ? "✓ \(track.label)" : track.label).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.edgedWide)
|
||||||
|
}
|
||||||
|
} buttons: {
|
||||||
|
Button("Close") { dismiss() }.buttonStyle(.tonal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title's episodes, from the player, opened scrolled to the one playing
|
||||||
|
/// so the next few are in view. Each row has its still, its name, and whether
|
||||||
|
/// it is playing, watched or part way.
|
||||||
|
private struct EpisodeSheet: View {
|
||||||
|
let episodes: [EpisodeRecord]
|
||||||
|
let playing: Int
|
||||||
|
let onSelect: (Int) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text("Episodes").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||||
|
.padding(.horizontal, 24).padding(.vertical, 8)
|
||||||
|
ScrollViewReader { reader in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(episodes.enumerated()), id: \.element.linkId) { position, episode in
|
||||||
|
row(position, episode).id(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.onAppear { reader.scrollTo(max(playing - 1, 0), anchor: .top) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationBackground(scheme.surfaceContainerLow)
|
||||||
|
.presentationCornerRadius(Corner.sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ position: Int, _ episode: EpisodeRecord) -> some View {
|
||||||
|
let current = position == playing
|
||||||
|
return Button {
|
||||||
|
dismiss()
|
||||||
|
onSelect(position)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
RemoteArtwork(episode.stillUrl, episode.label, fallback: episode.thumbnailSource, labelled: false)
|
||||||
|
.frame(width: 112)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let progress = episode.progress, !episode.watched, !current { AccentProgress(progress: progress) }
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(episode.providerName ?? episode.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
|
Text(current ? "Playing"
|
||||||
|
: episode.watched ? "\(episode.label) · Watched"
|
||||||
|
: episode.resumeAt != nil ? "\(episode.label) · Part way" : episode.label)
|
||||||
|
.textStyle(.bodySmall)
|
||||||
|
.foregroundStyle(current ? scheme.primary : scheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(current ? scheme.secondaryContainer : .clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.pressable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar that says playback is still going, and gets back to it.
|
||||||
|
///
|
||||||
|
/// Leaving the player does not stop it — the episode keeps playing — so there
|
||||||
|
/// has to be something on screen that says so and takes one tap to return to.
|
||||||
|
struct MiniTransport: View {
|
||||||
|
let host: PlayerHost
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let episode = host.episode, let title = host.title {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: episode.thumbnailSource)
|
||||||
|
.frame(width: 64)
|
||||||
|
.aspectRatio(16 / 9, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(episode.label).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface).lineLimit(1)
|
||||||
|
Text(title.displayName).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
let paused = host.player?.paused ?? true
|
||||||
|
IconButton(paused ? "play.fill" : "pause.fill", paused ? "Play" : "Pause", tint: scheme.onSurface) {
|
||||||
|
host.setPaused(!paused)
|
||||||
|
}
|
||||||
|
IconButton("xmark", "Stop playback", tint: scheme.onSurface, action: host.close)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.card()
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { host.minimized = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view libmpv draws into: its Metal layer, sized to this view.
|
||||||
|
struct MpvView: UIViewRepresentable {
|
||||||
|
let player: MpvPlayer
|
||||||
|
|
||||||
|
func makeUIView(context _: Context) -> LayerView {
|
||||||
|
LayerView(player: player)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_: LayerView, context _: Context) {}
|
||||||
|
|
||||||
|
final class LayerView: UIView {
|
||||||
|
private let player: MpvPlayer
|
||||||
|
private let video: CAMetalLayer
|
||||||
|
|
||||||
|
init(player: MpvPlayer) {
|
||||||
|
self.player = player
|
||||||
|
video = player.layer
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .black
|
||||||
|
video.removeFromSuperlayer()
|
||||||
|
layer.addSublayer(video)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = scale
|
||||||
|
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||||
|
CATransaction.commit()
|
||||||
|
// A new drawable is not enough on its own: mpv has to be told.
|
||||||
|
player.outputResized()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock to landscape, or hand rotation back to the device.
|
||||||
|
@MainActor
|
||||||
|
enum OrientationLock {
|
||||||
|
private(set) static var landscape = false
|
||||||
|
|
||||||
|
/// What the app delegate answers for every window.
|
||||||
|
static var mask: UIInterfaceOrientationMask { landscape ? .landscape : .all }
|
||||||
|
|
||||||
|
/// Reports which way it went.
|
||||||
|
static func toggle() -> Bool {
|
||||||
|
set(!landscape)
|
||||||
|
return landscape
|
||||||
|
}
|
||||||
|
|
||||||
|
static func set(_ locked: Bool) {
|
||||||
|
landscape = locked
|
||||||
|
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene else { return }
|
||||||
|
scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||||
|
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
The iOS app icon: Android's adaptive launcher icon, both layers on the full
|
||||||
|
108-unit canvas, since iOS cuts its own shape out of a square just as a
|
||||||
|
launcher does. Layer sources: android/app/src/main/res/drawable/
|
||||||
|
ic_launcher_background.xml and ic_launcher_foreground.xml. The glyph is
|
||||||
|
scaled up by 108/72: a launcher shows only the middle 72 units of the layer,
|
||||||
|
and iOS's mask takes far less off the edges.
|
||||||
|
Render with scripts/render-ios-icon.sh.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 108 108">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="face" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="108" y2="108">
|
||||||
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="glyph" gradientUnits="userSpaceOnUse" x1="0" y1="36" x2="0" y2="88">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="blocks">
|
||||||
|
<path d="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="108" height="108" fill="url(#face)"/>
|
||||||
|
<g transform="translate(54 54) scale(1.5) translate(-54 -54)">
|
||||||
|
<g transform="translate(-16 -8) translate(70 62) scale(0.6923) translate(-70 -62)">
|
||||||
|
<g transform="translate(0 3)" clip-path="url(#blocks)">
|
||||||
|
<path fill="#2E1596" fill-opacity="0.302" d="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z"/>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#blocks)">
|
||||||
|
<path fill="url(#glyph)" d="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x12",
|
||||||
|
"green" : "0x0E",
|
||||||
|
"red" : "0x0E"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||