Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a475ad3575 | ||
|
|
da13f0547e | ||
|
|
4ca57d9449 | ||
|
|
c9925b4b09 | ||
|
|
fe69d2fae4 | ||
|
|
3fe4900337 | ||
|
|
973a8f2749 | ||
|
|
cf8ee58f6b | ||
|
|
466630a9ba | ||
|
|
a3bc9023c7 | ||
|
|
7bfebc5fd5 | ||
|
|
87ccaf26e1 | ||
|
|
fafe0c3087 | ||
|
|
f66faf6b75 | ||
|
|
cd9e8eea81 | ||
|
|
c026f81283 | ||
|
|
c6d303f269 | ||
|
|
255e463fc8 | ||
|
|
3ebfd1a57b | ||
|
|
f4c08d03c7 | ||
|
|
a7b87cdc79 | ||
|
|
70952fa7ca | ||
|
|
3ca431feca | ||
|
|
bb5a6638c4 | ||
|
|
b5ba59b41d | ||
|
|
79f0d74e1a | ||
|
|
648c35e92a | ||
|
|
ffd6e959bb | ||
|
|
69d961d7b8 | ||
|
|
238ecd8220 | ||
|
|
65a669e231 | ||
|
|
658c620c82 | ||
|
|
c1487c31d0 | ||
|
|
a8a18be248 | ||
|
|
16c2b4c9d3 | ||
|
|
2871a13ba6 | ||
|
|
6e0be9e3c8 | ||
|
|
0868c2af69 | ||
|
|
4a3a7291b8 | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,68 @@
|
||||
name: Wiki
|
||||
|
||||
# docs/ is the source of the GitHub wiki. Every change to the documentation
|
||||
# checks its links; a push to main publishes the result, so the wiki follows
|
||||
# main. Pull requests only check: nothing unmerged reaches the wiki. Run it from
|
||||
# the Actions tab to publish another ref, such as a release tag.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check documentation links
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Check links
|
||||
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
|
||||
|
||||
publish:
|
||||
name: Publish to the wiki
|
||||
needs: check
|
||||
# Pull requests never publish.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: wiki-publish
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
|
||||
@@ -11,6 +11,110 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"serde",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_macos"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.38.0",
|
||||
"hashbrown 0.16.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
"async-channel",
|
||||
"async-executor",
|
||||
"async-task",
|
||||
"atspi",
|
||||
"futures-lite",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"zbus 5.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
"hashbrown 0.16.1",
|
||||
"static_assertions",
|
||||
"windows 0.62.2",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_winit"
|
||||
version = "0.32.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_macos",
|
||||
"accesskit_unix",
|
||||
"accesskit_windows",
|
||||
"raw-window-handle",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -360,6 +464,20 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -482,6 +600,43 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atspi"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
|
||||
dependencies = [
|
||||
"atspi-common",
|
||||
"atspi-proxies",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atspi-common"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zbus 5.19.0",
|
||||
"zbus-lockstep",
|
||||
"zbus-lockstep-macros",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atspi-proxies"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
|
||||
dependencies = [
|
||||
"atspi-common",
|
||||
"serde",
|
||||
"zbus 5.19.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -2017,6 +2172,7 @@ version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -4688,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pgp"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||
dependencies = [
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
@@ -5062,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.7.3"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f"
|
||||
checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
@@ -5089,9 +5245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.7.3"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0"
|
||||
checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
@@ -5118,11 +5274,12 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"futures",
|
||||
@@ -5142,7 +5299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5176,7 +5333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5195,7 +5352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -5215,7 +5372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -5228,7 +5385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
@@ -5243,7 +5400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
@@ -5870,7 +6027,7 @@ dependencies = [
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"zbus",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7229,6 +7386,11 @@ name = "uuid"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
@@ -8544,9 +8706,68 @@ dependencies = [
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros 5.19.0",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-lockstep"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
|
||||
dependencies = [
|
||||
"zbus_xml",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus-lockstep-macros"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zbus-lockstep",
|
||||
"zbus_xml",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8559,7 +8780,22 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
"zvariant_utils 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8570,7 +8806,39 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_xml"
|
||||
version = "5.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zbus_names 4.3.4",
|
||||
"zvariant 5.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8704,7 +8972,22 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive 5.15.0",
|
||||
"zvariant_utils 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8717,7 +9000,20 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"zvariant_utils 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8730,3 +9026,16 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
opt-level = 2
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.7.3" }
|
||||
proton-drive-rs = { version = "0.7.3" }
|
||||
proton-sdk = { version = "0.9.0" }
|
||||
proton-drive-rs = { version = "0.9.0" }
|
||||
|
||||
# Internal crates.
|
||||
pstr-core = { path = "crates/pstr-core" }
|
||||
@@ -73,8 +73,10 @@ keyring = { version = "3" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
||||
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
||||
# eframe is drawing into.
|
||||
# eframe is drawing into. `accesskit` hands the widget tree to the platform's
|
||||
# screen reader (AT-SPI, UI Automation, NSAccessibility).
|
||||
eframe = { version = "0.35", default-features = false, features = [
|
||||
"accesskit",
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"wayland",
|
||||
|
||||
@@ -26,6 +26,23 @@ indicator while a crawl runs, but not the crawl's progress.
|
||||
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||
tests) passes, and Android lint runs in CI.
|
||||
|
||||
A source review on 2026-10-12 found these still short of the text below:
|
||||
|
||||
- **Title page.** The backdrop scrolls away with the back button instead of
|
||||
collapsing to a pinned bar, and the icon row has no mark-watched action.
|
||||
- **Episode rows** show the file size where runtime was asked for.
|
||||
- **Player episode list** is a bottom sheet in landscape too, not a side
|
||||
panel.
|
||||
- **Storage** has a usage bar for the streaming cache only; offline episodes
|
||||
have none.
|
||||
- **Metadata language** is still a free-text field.
|
||||
- **Touch targets.** The player's icons are 40 dp, under the 48 dp floor.
|
||||
- **Screenshots** do not cover the player, and only the library is taken at
|
||||
font scale 1.5.
|
||||
|
||||
Share rows now say how many files the last crawl found, when it ran and why it
|
||||
failed (section 2, Shares), as of the catalog's `share_crawls` table.
|
||||
|
||||
## 0. CI (done)
|
||||
|
||||
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||
|
||||
@@ -1,83 +1,145 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||
|
||||
# proton-stream
|
||||
|
||||
A Netflix-style cross-platform client for **Proton Drive public links**.
|
||||
**Your Proton Drive, streaming.** A Netflix-style library for the videos in your Proton Drive and
|
||||
the share links people send you, on Linux, Windows, Android, Android TV and iOS.
|
||||
|
||||
Paste a share URL and its password; get a browsable, streamable library. No
|
||||
Proton account, no server to host, no download step — episodes start playing in
|
||||
seconds and the seek bar works.
|
||||
**[proton-stream.narl.io](https://proton-stream.narl.io)**
|
||||
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust
|
||||
Proton Drive SDK.
|
||||
[](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
|
||||
[](https://github.com/narrrl/proton-stream/releases/latest)
|
||||
[](#license)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/desktop-home.png" alt="The library in the desktop app" width="820">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||
> to describe what the software talks to. It provides no content: it plays what you keep in
|
||||
> Proton Drive or what is shared with you.
|
||||
|
||||
## Features
|
||||
|
||||
**Library**
|
||||
|
||||
- **Public share links or your own Drive.** Paste a share link, with its password if it has one,
|
||||
or sign in and pick folders of your Drive. Any number of them merge into one library.
|
||||
- **Seasons and episodes from your folders.** The folder names the show, the file name gives the
|
||||
season and episode, and a file without numbers is a film.
|
||||
- **Posters, synopses and shelves**, from AniList or TMDB, **off by default**: shelves such as
|
||||
*Directed by* and *From MADHOUSE*, a franchise folded into one tile, and a hand-picked match
|
||||
that is never overwritten.
|
||||
- **Filters and sorting:** series, films, watching, not started, downloaded; by name, last watched,
|
||||
last added, air date, rating or popularity.
|
||||
|
||||
**Player**
|
||||
|
||||
- **Streams, never downloads first.** Episodes start in seconds, and a seek fetches only what it
|
||||
lands on.
|
||||
- **libmpv inside the app's own window:** HEVC 10-bit, HDR and ASS subtitles with hardware
|
||||
decoding, and no transcoding.
|
||||
- **Skip the opening and the ending** by chapter, an **Up next** countdown, and next and previous
|
||||
episode.
|
||||
- **Subtitle files beside the video** (`.srt`, `.ass`, `.ssa`, `.vtt`), and **the dialogue track
|
||||
over *Signs & Songs***. Audio and subtitle choices are remembered for each show.
|
||||
|
||||
**Everywhere**
|
||||
|
||||
- **Resume on any device.** Sign in, and your place in every episode, your shares and your
|
||||
settings sync through your own Drive.
|
||||
- **Downloads** of an episode, a season or a show, to play offline.
|
||||
- **Android 12+ and Android TV** with Material 3, background audio and new-episode
|
||||
notifications; **iOS 17+** as a SwiftUI port for SideStore and AltStore.
|
||||
- **Themes:** a near-black default, four Catppuccin flavours and Persona 5, each with nine accents.
|
||||
- **Full keyboard control** on the desktop, and the `pstr` command line.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="images/desktop-library.png" alt="The whole library, filtered and sorted"></td>
|
||||
<td width="50%">
|
||||
<img src="images/android-home.png" alt="Android: home" width="24%">
|
||||
<img src="images/android-library.png" alt="Android: all titles" width="24%">
|
||||
<img src="images/android-title.png" alt="Android: a title page" width="24%">
|
||||
<img src="images/android-episodes.png" alt="Android: episodes and downloads" width="24%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Install
|
||||
|
||||
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has:
|
||||
|
||||
| Platform | Download |
|
||||
|---|---|
|
||||
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` |
|
||||
| Fedora | `proton-stream-<version>-1.x86_64.rpm` |
|
||||
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` |
|
||||
| Windows | `proton-stream-<version>-x64.msi`, or the portable `…-x86_64-windows.zip` |
|
||||
| Android, Android TV | `proton-stream-<version>-android.apk` |
|
||||
| iOS, iPadOS | `proton-stream-<version>-ios.ipa`, for SideStore or AltStore |
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-stream_*_amd64.deb # Debian, Ubuntu
|
||||
sudo dnf install ./proton-stream-*.x86_64.rpm # Fedora
|
||||
cd packaging && makepkg -si # Arch, from a checkout
|
||||
```
|
||||
|
||||
Then open **Proton Stream**, paste a share link on the **Shares** page (or press **Ctrl+V**), and
|
||||
the library fills in. [Getting started](docs/GETTING_STARTED.md) walks through it, and
|
||||
[Installation](docs/INSTALL.md) covers every platform, updating and uninstalling.
|
||||
|
||||
## Documentation
|
||||
|
||||
The [wiki](https://github.com/narrrl/proton-stream/wiki) is generated from [`docs/`](docs/) on
|
||||
every push to `main`:
|
||||
|
||||
- [Getting started](docs/GETTING_STARTED.md), [Installation](docs/INSTALL.md) and the
|
||||
[FAQ](docs/FAQ.md)
|
||||
- [User guide](docs/USER_GUIDE.md), [Account and sync](docs/ACCOUNT_AND_SYNC.md) and
|
||||
[Android and iOS](docs/MOBILE.md)
|
||||
- [Command-line reference](docs/CLI.md), [Privacy](docs/PRIVACY.md) and
|
||||
[Troubleshooting](docs/TROUBLESHOOTING.md)
|
||||
- For contributors: [Development](docs/DEVELOPMENT.md), [Architecture](docs/ARCHITECTURE.md),
|
||||
[Testing](docs/TESTING.md), [Android](docs/ANDROID.md) and [iOS](docs/IOS.md)
|
||||
|
||||
## Why it works
|
||||
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet
|
||||
decrypted under one shared session key. There is no sequential streaming session
|
||||
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode
|
||||
is playable without downloading 2 GB of it.
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
|
||||
shared session key. There is no sequential streaming session to resume, so seeking costs exactly
|
||||
the blocks the seek lands on: a 2 GB episode plays without downloading 2 GB of it.
|
||||
|
||||
## Status
|
||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: **2.3 s to first
|
||||
frame, about 1.5 s to resume after dragging to an arbitrary point, and 61 MiB fetched for the
|
||||
first 38 seconds of playback.** Watch it again and the disk cache serves it with **no network at
|
||||
all**: 0.3 s to first frame.
|
||||
|
||||
Early, but it is an app now: the picture lands **inside the window**, not in an
|
||||
mpv window of its own. The SDK streaming surface, the catalog, the streaming
|
||||
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,
|
||||
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"
|
||||
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
|
||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
||||
The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
|
||||
no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
|
||||
whole data flow.
|
||||
|
||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
||||
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).
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
|
||||
SDK.
|
||||
|
||||
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).
|
||||
## Privacy
|
||||
|
||||
```bash
|
||||
cargo run --release -p pstr-app # the app
|
||||
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||
```
|
||||
- **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
|
||||
posters and descriptions, which sends your title names to AniList or TMDB.
|
||||
- **The part of a share link after `#` is its decryption key.** It, any link password and your
|
||||
Proton session live in the system credential store (Secret Service, Credential Manager, the
|
||||
Android Keystore, the iOS Keychain), never in a config file.
|
||||
- **Sync is end-to-end encrypted.** Watch history, shares and settings travel through your own
|
||||
Drive, one file per device.
|
||||
- **The block cache and downloads hold decrypted video** in your cache and data folders. Size or
|
||||
empty the cache in **Settings → Storage**.
|
||||
|
||||
One window at a time: a second launch brings the open window forward and
|
||||
hands it any share link it was given, then exits.
|
||||
|
||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||
is broken:
|
||||
|
||||
```bash
|
||||
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
|
||||
cargo run -p pstr-cli -- crawl
|
||||
cargo run -p pstr-cli -- list
|
||||
cargo run -p pstr-cli -- metadata # opt-in; off unless configured
|
||||
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache
|
||||
cargo run --release -p pstr-cli -- bench --file Frieren --verify
|
||||
```
|
||||
|
||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link:
|
||||
**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point,
|
||||
61 MiB fetched for the first 38 seconds of playback.** Watch it again and the
|
||||
disk cache serves it with **no network at all** — 0.3 s to first frame.
|
||||
|
||||
`bench` measures the same file without a player, reporting cold seek latency,
|
||||
sustained throughput and peak memory, and `--verify` cross-checks a mid-file read
|
||||
against the SDK's own range download.
|
||||
|
||||
## Design
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Fat client. No server; each viewer's app talks to Proton directly. |
|
||||
| Auth | Public links only — but several, merged into one library. |
|
||||
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||
| Themes | The shipped near-black palette, 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). |
|
||||
| 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. |
|
||||
[Privacy](docs/PRIVACY.md) lists every connection and every file.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -85,53 +147,26 @@ Rust 2024, MSRV 1.96.
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo run --release -p pstr-app # the app
|
||||
cargo run -p pstr-cli -- add --name anime 'URL' # the same work, headless
|
||||
```
|
||||
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
|
||||
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
|
||||
On Windows the libmpv ones
|
||||
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||
crates.io; nothing here builds against a sibling checkout.
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
|
||||
WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
|
||||
|
||||
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`,
|
||||
and a portable `.zip` plus an `.msi` on Windows — are produced by
|
||||
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
||||
per-platform detail.
|
||||
|
||||
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;
|
||||
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
|
||||
|
||||
- The share URL fragment **is** the decryption password. It and any custom
|
||||
password live in the OS credential store — Secret Service on Linux, Credential
|
||||
Manager on Windows — never in a config file.
|
||||
- Nothing leaves your machine except requests to Proton, unless you turn metadata
|
||||
enrichment on. The player is fed through an in-process mpv stream callback, not
|
||||
a local HTTP server: there is no port for another process on the machine to ask
|
||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
||||
would put a share secret in its log and window title.
|
||||
- The block cache holds decrypted content. It lives in your cache directory,
|
||||
can be deleted at any time the app is not running, and can be sized or
|
||||
emptied from Settings → Storage.
|
||||
- A share link given as a command-line argument is visible to other users of
|
||||
the machine in the process list while that launch runs, and may land in
|
||||
shell history. Pasting it into the window (Ctrl+V) does neither.
|
||||
- The desktop's media widget is handed the title, the episode and a `file://`
|
||||
path to artwork already on disk — never a provider URL, so the shell never
|
||||
contacts a metadata provider on its own.
|
||||
- **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
|
||||
`scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
|
||||
[`packaging/README.md`](packaging/README.md).
|
||||
- **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
|
||||
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||
- **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
|
||||
[`docs/IOS.md`](docs/IOS.md).
|
||||
|
||||
## License
|
||||
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android and
|
||||
iOS applications and their combined native distributions are GPL-3.0-or-later; see
|
||||
[`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 shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
|
||||
and their combined native distributions are GPL-3.0-or-later; see
|
||||
[`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
|
||||
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.
|
||||
@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
|
||||
*/
|
||||
@Test
|
||||
fun chapterPlanningCrossesTheBoundaryIntact() {
|
||||
val plan = chapterPlan(emptyList(), 1560.0)
|
||||
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
|
||||
assertNotNull(plan)
|
||||
assertNull(skipOffer(plan, 12.0))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<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
|
||||
android:name=".ProtonStreamApplication"
|
||||
android:allowBackup="false"
|
||||
@@ -14,6 +21,7 @@
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="33"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ProtonStream">
|
||||
@@ -35,11 +43,11 @@
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Proton Drive public links. autoVerify="false", said explicitly:
|
||||
|
||||
@@ -27,6 +27,7 @@ extern "C" {
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -202,7 +203,7 @@ struct PlaybackState {
|
||||
/// and must not be attributed to the file being loaded.
|
||||
bool loading = false;
|
||||
/// The picture's display size, once mpv has decoded enough to know it.
|
||||
/// Zero until then, and the aspect Picture-in-Picture is given.
|
||||
/// Zero until then.
|
||||
double video_width = 0.0;
|
||||
double video_height = 0.0;
|
||||
/// Which loaded file this state describes. Bumped by every load, so a
|
||||
@@ -210,6 +211,17 @@ struct PlaybackState {
|
||||
/// episode one — which is the difference between resuming an episode where
|
||||
/// the viewer left it and resuming it where the *previous* one ended.
|
||||
int64_t generation = 0;
|
||||
/// 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 {
|
||||
@@ -311,8 +323,11 @@ class Player {
|
||||
// needs no window, and a load that cannot get one plays audio anyway —
|
||||
// waiting for a surface here is what made background playback fail after
|
||||
// a five-second stall.
|
||||
if (!audio.empty()) set_string("alang", audio);
|
||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||
//
|
||||
// Set even when empty: mpv keeps both across `loadfile`, so a file
|
||||
// with no language preference used to inherit the last one's.
|
||||
set_string("alang", audio);
|
||||
set_string("slang", subtitle);
|
||||
set_string("sid", subtitles ? "auto" : "no");
|
||||
pending_start_.store(std::max(0.0, start));
|
||||
// Retire the outgoing file's position, duration and end flag *before*
|
||||
@@ -338,7 +353,10 @@ class Player {
|
||||
state_.generation = generation;
|
||||
state_.loading = true;
|
||||
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 char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||
if (mpv_command(mpv_, command) < 0) {
|
||||
@@ -372,6 +390,27 @@ class Player {
|
||||
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 {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
return state_;
|
||||
@@ -395,6 +434,8 @@ class Player {
|
||||
char *lang_raw = nullptr;
|
||||
char *title_raw = nullptr;
|
||||
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 + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||
MpvString type(type_raw);
|
||||
@@ -403,10 +444,14 @@ class Player {
|
||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||
MpvString title(title_raw);
|
||||
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 += ',';
|
||||
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||
"\",\"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 + ']';
|
||||
}
|
||||
@@ -434,6 +479,35 @@ class Player {
|
||||
}
|
||||
|
||||
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 {
|
||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||
};
|
||||
@@ -534,6 +608,17 @@ class Player {
|
||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||
const double start = pending_start_.exchange(0.0);
|
||||
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) {
|
||||
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||
@@ -757,6 +842,12 @@ class Player {
|
||||
bool surface_changed_ = false;
|
||||
std::atomic<bool> frame_ready_{false};
|
||||
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); }
|
||||
@@ -850,6 +941,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
||||
: 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 {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||
}
|
||||
@@ -881,8 +983,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
||||
state.video_width, state.video_height,
|
||||
static_cast<jdouble>(state.end_reason),
|
||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||
state.seeking ? 1.0 : 0.0, state.cached_until};
|
||||
constexpr jsize kFields = 14;
|
||||
state.seeking ? 1.0 : 0.0, state.cached_until,
|
||||
state.loaded ? 1.0 : 0.0};
|
||||
constexpr jsize kFields = 15;
|
||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||
|
||||
@@ -1,54 +1,38 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||
import io.narl.protonstream.sync.WatchSyncWorker
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
private val inPictureInPicture = mutableStateOf(false)
|
||||
|
||||
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||
@@ -69,30 +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(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before anything draws. The palette decides the window's own
|
||||
@@ -116,34 +80,17 @@ class MainActivity : ComponentActivity() {
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
pictureInPictureControls,
|
||||
IntentFilter(ACTION_PIP_CONTROL),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
NewEpisodesWorker.schedule(this)
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
||||
// building the params inside onUserLeaveHint is too late for the swipe
|
||||
// home that gesture navigation sends, which never calls it. Re-register
|
||||
// whenever the picture's shape or the transport's does.
|
||||
lifecycleScope.launch {
|
||||
snapshotFlow { playerHost.value }
|
||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
||||
.map { state -> PictureInPictureShape.of(state) }
|
||||
.distinctUntilChanged()
|
||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
||||
}
|
||||
setContent {
|
||||
ProtonStreamTheme {
|
||||
ProtonStreamApp(
|
||||
playerHost.value,
|
||||
inPictureInPicture.value,
|
||||
shareLink = incomingShareLink.value,
|
||||
onShareLinkTaken = { incomingShareLink.value = null },
|
||||
)
|
||||
@@ -178,27 +125,7 @@ class MainActivity : ComponentActivity() {
|
||||
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// The backstop for launchers that still send this instead of arming
|
||||
// auto-enter. Entering twice is not an error; not entering at all is.
|
||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
||||
runCatching {
|
||||
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
// The window is now a thumbnail with its own controls. Anything the
|
||||
// player would draw over it — the transport, the title bar, the skip
|
||||
// offer — is unreadable at that size and covers the picture.
|
||||
inPictureInPicture.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
@@ -213,87 +140,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!isInPictureInPictureMode &&
|
||||
!SettingsStore(this).backgroundAudio
|
||||
) {
|
||||
playerHost.value?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
||||
private data class PictureInPictureShape(
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
||||
active = state != null && state.duration > 0.0 && !state.ended,
|
||||
paused = state?.paused != false,
|
||||
width = state?.videoWidth?.toInt() ?: 0,
|
||||
height = state?.videoHeight?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
||||
PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio(width, height))
|
||||
.setAutoEnterEnabled(active)
|
||||
.setSeamlessResizeEnabled(false)
|
||||
// The player owns the whole window, so the picture's rectangle is
|
||||
// the window's. Without it the transition into PiP cross-fades from
|
||||
// a screenshot instead of shrinking the video that is already there.
|
||||
.setSourceRectHint(windowBounds())
|
||||
.setActions(
|
||||
listOf(
|
||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
||||
|
||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
||||
RemoteAction(
|
||||
Icon.createWithResource(this, icon),
|
||||
label,
|
||||
label,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
||||
const val EXTRA_CONTROL = "control"
|
||||
const val CONTROL_PLAY = "play"
|
||||
const val CONTROL_PAUSE = "pause"
|
||||
const val CONTROL_NEXT = "next"
|
||||
const val CONTROL_PREVIOUS = "previous"
|
||||
|
||||
/**
|
||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
||||
* and refuses the request outright rather than clamping it — so a
|
||||
* 2.40:1 scope release would take PiP away entirely.
|
||||
*/
|
||||
fun aspectRatio(width: Int, height: Int): Rational {
|
||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
||||
val ratio = width.toDouble() / height
|
||||
return when {
|
||||
ratio > MAX_RATIO -> Rational(239, 100)
|
||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
||||
else -> Rational(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
const val MAX_RATIO = 2.39
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,15 @@ package io.narl.protonstream
|
||||
|
||||
import android.app.Application
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
|
||||
class ProtonStreamApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NativeRuntime.initialize(this)
|
||||
// SharedPreferences reads its file on first use, and the first use was
|
||||
// on the main thread — in the playback service's start callback among
|
||||
// others. Read here, off it, and every later read is from memory.
|
||||
Thread({ SettingsStore.preload(this) }, "settings-preload").start()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
@@ -25,12 +29,27 @@ class OfflineDownloadWorker(
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(null))
|
||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
val store = DownloadStateStore(applicationContext)
|
||||
setForeground(createForegroundInfo(label, null))
|
||||
postSummary(store)
|
||||
try {
|
||||
return transfer(store, shareId, volumeId, linkId, label)
|
||||
} finally {
|
||||
postSummary(store)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun transfer(
|
||||
store: DownloadStateStore,
|
||||
shareId: String,
|
||||
volumeId: String,
|
||||
linkId: String,
|
||||
label: String,
|
||||
): Result {
|
||||
// Queued, not running: the engine lets two downloads transfer at once
|
||||
// and holds the rest back, and only the first progress report means
|
||||
// this one has a slot.
|
||||
@@ -62,7 +81,7 @@ class OfflineDownloadWorker(
|
||||
reportedAt = now
|
||||
reportedPercent = percent
|
||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||
setForegroundAsync(createForegroundInfo(percent))
|
||||
setForegroundAsync(createForegroundInfo(label, percent))
|
||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||
// this read-modify-write used to put RUNNING back over the
|
||||
// viewer's PAUSED and then report the download as cancelled.
|
||||
@@ -120,15 +139,20 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
/** [progress] is a percentage, or null while waiting for a download slot. */
|
||||
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
|
||||
/**
|
||||
* This episode's line, under [GROUP]: a season is fourteen workers, and
|
||||
* fourteen loose notifications saying the same thing named nothing.
|
||||
* [progress] is a percentage, or null while waiting for a download slot.
|
||||
*/
|
||||
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(
|
||||
if (progress == null) "Waiting to download" else "Making episode available offline",
|
||||
)
|
||||
.setContentTitle(label)
|
||||
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.build()
|
||||
return ForegroundInfo(
|
||||
stableNotificationId(id),
|
||||
@@ -137,6 +161,36 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line the group folds into — "Downloading 12 episodes" — or none
|
||||
* once nothing is left to download. Posted as each worker starts and
|
||||
* ends rather than on progress, which each episode's own line carries.
|
||||
*/
|
||||
private fun postSummary(store: DownloadStateStore) {
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
val left = store.records().count {
|
||||
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
|
||||
}
|
||||
if (left == 0) {
|
||||
manager.cancel(SUMMARY_ID)
|
||||
return
|
||||
}
|
||||
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed) return
|
||||
manager.notify(
|
||||
SUMMARY_ID,
|
||||
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.setGroupSummary(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||
|
||||
companion object {
|
||||
@@ -149,6 +203,10 @@ class OfflineDownloadWorker(
|
||||
const val KEY_TOTAL = "total"
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val CHANNEL_ID = "offline-downloads"
|
||||
private const val GROUP = "offline-downloads"
|
||||
|
||||
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
|
||||
internal const val SUMMARY_ID = 1
|
||||
|
||||
/** Shortest gap between two identical-looking progress reports. */
|
||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||
@@ -186,4 +244,4 @@ internal class TransferRate {
|
||||
}
|
||||
|
||||
internal fun stableNotificationId(workId: UUID): Int =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.ui.ThumbnailSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -19,7 +20,11 @@ import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import uniffi.pstr_android.ChapterPlan
|
||||
import uniffi.pstr_android.ChapterRecord
|
||||
import uniffi.pstr_android.SkipRangeRecord
|
||||
import uniffi.pstr_android.SubtitleFilesRecord
|
||||
import uniffi.pstr_android.SubtitleTrackRecord
|
||||
import uniffi.pstr_android.chapterPlan
|
||||
import uniffi.pstr_android.fullSubtitlesInstead
|
||||
|
||||
/**
|
||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||
@@ -85,9 +90,9 @@ data class MpvPlaybackState(
|
||||
/**
|
||||
* What is on screen, for the surfaces that are not the player.
|
||||
*
|
||||
* The media session, its notification and Picture-in-Picture all have to name
|
||||
* the episode, and none of them can reach the composition that knows it. The
|
||||
* player publishes this once per episode and they read it.
|
||||
* The media session and its notification have to name the episode, and
|
||||
* neither can reach the composition that knows it. The player publishes this
|
||||
* once per episode and they read it.
|
||||
*/
|
||||
data class NowPlaying(
|
||||
val media: String,
|
||||
@@ -101,12 +106,25 @@ data class NowPlaying(
|
||||
val hasNext: Boolean,
|
||||
)
|
||||
|
||||
/** A sleep timer that is running. */
|
||||
sealed interface SleepTimer {
|
||||
/** Stop when [media] ends, rather than going on to the next episode. */
|
||||
data class EndOf(val media: String) : SleepTimer
|
||||
|
||||
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
|
||||
data class At(val at: Long) : SleepTimer
|
||||
}
|
||||
|
||||
data class MpvTrack(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val title: String,
|
||||
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)
|
||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||
@@ -123,19 +141,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||
private val mutableAdvancing = MutableStateFlow(false)
|
||||
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
|
||||
private var sleepJob: Job? = null
|
||||
private var poller: Job? = null
|
||||
/** The episode key and load generation the native player currently holds. */
|
||||
@Volatile private var currentMedia: String? = null
|
||||
@Volatile private var currentGeneration: Double = -1.0
|
||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||
/** What AniSkip said about the open episode, keyed by that episode. */
|
||||
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
|
||||
/** The load generation whose subtitle pick has been looked at. */
|
||||
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||
var onPlaybackStarted: (() -> Unit)? = null
|
||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||
var onExplicitStop: (() -> Unit)? = null
|
||||
/**
|
||||
* Where a transport outside the player sends previous/next.
|
||||
*
|
||||
* The player owns which episode is open, so the notification, the lock
|
||||
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
|
||||
* The player owns which episode is open, so the notification and the lock
|
||||
* screen cannot walk the playlist themselves. Both
|
||||
* run on the main thread, which is where the player's state lives.
|
||||
*/
|
||||
var onSkipPrevious: (() -> Unit)? = null
|
||||
@@ -166,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
*/
|
||||
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||
|
||||
/**
|
||||
* The sleep timer. Kept here rather than on the screen, because the
|
||||
* episode it is about keeps playing with the screen off.
|
||||
*/
|
||||
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
|
||||
|
||||
/** Start a sleep timer, replacing any running one, or turn it off. */
|
||||
fun setSleepTimer(timer: SleepTimer?) {
|
||||
sleepJob?.cancel()
|
||||
mutableSleep.value = timer
|
||||
if (timer is SleepTimer.At) {
|
||||
sleepJob = scope.launch {
|
||||
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
|
||||
mutableSleep.value = null
|
||||
setPaused(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the current [problem]. */
|
||||
fun clearProblem() { mutableProblem.value = null }
|
||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||
@@ -217,6 +260,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
mutableTracks.value = emptyList()
|
||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||
plannedFor = null
|
||||
skipRanges = null
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
@@ -245,7 +289,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
*/
|
||||
fun beginTransition() { mutableAdvancing.value = true }
|
||||
|
||||
/** Names the open episode for the media session, its notification and PiP. */
|
||||
/** Names the open episode for the media session and its notification. */
|
||||
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
@@ -259,6 +303,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
}
|
||||
|
||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||
|
||||
/**
|
||||
* Take AniSkip's answer for [media], and plan the chapters again with it.
|
||||
* Dropped when another episode has opened since it was asked for.
|
||||
*/
|
||||
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
|
||||
if (media != currentMedia) return
|
||||
skipRanges = media to ranges
|
||||
plannedFor = null
|
||||
refreshChapters(mutableState.value)
|
||||
}
|
||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||
|
||||
@@ -267,7 +322,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
fun selectTrack(track: MpvTrack?) {
|
||||
val audio = track?.type == "audio"
|
||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||
refreshTracks()
|
||||
// mpv makes the switch on its own thread and lists it a moment later,
|
||||
// so a track list read back now still ticks the old track. The tick
|
||||
// moves here, and a read shortly after confirms it.
|
||||
val type = if (audio) "audio" else "sub"
|
||||
mutableTracks.value = mutableTracks.value.map {
|
||||
if (it.type == type) it.copy(selected = it.id == track?.id) else it
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
delay(TRACK_SETTLE_MS)
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,6 +357,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
|
||||
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() {
|
||||
if (poller?.isActive == true) return
|
||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||
@@ -322,6 +411,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
// both of which are the main thread's.
|
||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||
refreshChapters(next)
|
||||
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
|
||||
subtitlesCheckedFor != currentGeneration
|
||||
) {
|
||||
subtitlesCheckedFor = currentGeneration
|
||||
preferFullSubtitles()
|
||||
}
|
||||
}
|
||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||
if (tick++ % 4 == 0) refreshTracks()
|
||||
@@ -355,17 +450,37 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
}
|
||||
}.onSuccess { chapters ->
|
||||
plannedFor = wanted
|
||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
|
||||
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
|
||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||
val values = JSONArray(encoded)
|
||||
List(values.length()) { index ->
|
||||
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 }
|
||||
@@ -414,6 +529,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
subtitles: Boolean,
|
||||
hardwareDecoding: 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 nativeSeek(handle: Long, position: Double)
|
||||
private external fun nativeVolume(handle: Long, volume: Double)
|
||||
@@ -434,7 +557,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
|
||||
companion object {
|
||||
/** 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 WIDTH = 7
|
||||
private const val HEIGHT = 8
|
||||
@@ -443,6 +566,10 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
private const val CACHE_PERCENT = 11
|
||||
private const val SEEKING = 12
|
||||
private const val CACHED_UNTIL = 13
|
||||
private const val LOADED = 14
|
||||
|
||||
/** How long after a track switch mpv's track list is read again. */
|
||||
private const val TRACK_SETTLE_MS = 300L
|
||||
|
||||
init {
|
||||
System.loadLibrary("pstr_android")
|
||||
|
||||
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.loadArtwork
|
||||
import io.narl.protonstream.ui.loadThumbnail
|
||||
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||
class PlaybackService : Service() {
|
||||
inner class PlaybackBinder : Binder() {
|
||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
||||
private var notified: NotificationShape? = null
|
||||
/** The pending teardown, held so an episode transition can call it off. */
|
||||
private var retire: Job? = null
|
||||
/** The open episode's latest reading, which [track] saves. */
|
||||
private var lastReading: MpvPlaybackState? = null
|
||||
private var lastSave = 0L
|
||||
/** The episode's artwork, and the source key it was fetched for. */
|
||||
private var artwork: Pair<String, Bitmap>? = null
|
||||
private val binder = PlaybackBinder()
|
||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it) }
|
||||
onStateChanged = { updateSession(it); track(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Swiped away from Recents. With background audio on, this service would
|
||||
* otherwise play on with no screen to return to and no autoplay, so it
|
||||
* saves where the episode got to and stops.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
lastReading?.let { reading ->
|
||||
// Bounded and on this thread: the process may go as soon as the
|
||||
// service stops, and a save launched into its scope would go with it.
|
||||
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||
}
|
||||
host?.stop()
|
||||
retirePlayback()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the resume position from here rather than from the player screen.
|
||||
*
|
||||
* This service outlives the activity whenever background audio is on, and a
|
||||
* position the screen alone kept stopped being saved the moment the screen
|
||||
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||
* change saves the outgoing episode's last reading before the new one's.
|
||||
*/
|
||||
private fun track(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
if (state.duration <= 0.0) return
|
||||
val previous = lastReading
|
||||
lastReading = state
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val due = when {
|
||||
previous != null && previous.media != media -> {
|
||||
scope.launch(Dispatchers.IO) { save(previous) }
|
||||
true
|
||||
}
|
||||
state.ended -> previous?.ended != true
|
||||
state.paused -> previous?.paused != true
|
||||
else -> now - lastSave >= SAVE_EVERY_MS
|
||||
}
|
||||
if (due) {
|
||||
lastSave = now
|
||||
scope.launch(Dispatchers.IO) { save(state) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
// Played to the end, or near enough that what is left is credits. A
|
||||
// failure or a stop also "ends", and recording either as watched would
|
||||
// retire an episode nobody saw.
|
||||
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||
runCatching {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
media.substringBeforeLast(':'),
|
||||
media.substringAfterLast(':'),
|
||||
state.position.coerceIn(0.0, state.duration),
|
||||
state.duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
refreshSession(state, host?.nowPlaying?.value)
|
||||
if (state.ended) scheduleRetire() else cancelRetire()
|
||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
||||
private const val TRANSITION_GRACE_MS = 30_000L
|
||||
/** How far the volume drops for a navigation prompt or a notification. */
|
||||
private const val DUCK_FACTOR = 0.3
|
||||
private const val SAVE_EVERY_MS = 5_000L
|
||||
/** How long a swipe from Recents may hold the main thread to save. */
|
||||
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||
|
||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.BrightnessMedium
|
||||
import androidx.compose.material.icons.filled.FastForward
|
||||
import androidx.compose.material.icons.filled.FastRewind
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What a swipe on the picture is doing, for the bubble that says so. */
|
||||
sealed interface Swipe {
|
||||
/** The window's brightness, from 0 to 1. */
|
||||
data class Brightness(val level: Float) : Swipe
|
||||
|
||||
/** The device's media volume, from 0 to 1. */
|
||||
data class Volume(val level: Float) : Swipe
|
||||
|
||||
/** Where the playhead will land when the finger lifts, and where it was. */
|
||||
data class Seek(val from: Double, val to: Double) : Swipe
|
||||
}
|
||||
|
||||
/** How much of the file one swipe across the whole width is worth, in seconds. */
|
||||
private const val SEEK_SPAN_SECONDS = 90.0
|
||||
|
||||
/**
|
||||
* A finger that comes down this close to the top or bottom of the screen is
|
||||
* the system's: pulling down the shade, swiping home.
|
||||
*/
|
||||
private val SYSTEM_EDGE = 48.dp
|
||||
|
||||
/** Which way a swipe went, settled by its first movement past the touch slop. */
|
||||
private enum class Axis { Brightness, Volume, Seek, Ignored }
|
||||
|
||||
/**
|
||||
* The swipes every phone video player has taught: up and down on the left for
|
||||
* brightness, on the right for volume, and sideways to seek — shown while the
|
||||
* finger is down and only seeking when it lifts, because a seek per movement
|
||||
* would throw away every block in flight.
|
||||
*/
|
||||
fun Modifier.playerSwipes(
|
||||
enabled: Boolean,
|
||||
activity: Activity?,
|
||||
position: () -> Double,
|
||||
duration: () -> Double,
|
||||
onSwipe: (Swipe?) -> Unit,
|
||||
onSeek: (Double) -> Unit,
|
||||
): Modifier = pointerInput(enabled, activity) {
|
||||
if (!enabled) return@pointerInput
|
||||
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val edge = SYSTEM_EDGE.toPx()
|
||||
var axis = Axis.Ignored
|
||||
var start = Offset.Zero
|
||||
var travelled = Offset.Zero
|
||||
var level = 0f
|
||||
var seekFrom = 0.0
|
||||
var seekTo = 0.0
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
start = offset
|
||||
travelled = Offset.Zero
|
||||
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
|
||||
},
|
||||
onDragEnd = {
|
||||
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
|
||||
onSwipe(null)
|
||||
},
|
||||
onDragCancel = { onSwipe(null) },
|
||||
) { change, amount ->
|
||||
if (axis == Axis.Ignored) return@detectDragGestures
|
||||
change.consume()
|
||||
val first = travelled == Offset.Zero
|
||||
travelled += amount
|
||||
if (first) {
|
||||
// The first movement past the slop decides what the swipe is for.
|
||||
axis = when {
|
||||
abs(amount.x) > abs(amount.y) -> Axis.Seek
|
||||
start.x < size.width / 2 -> Axis.Brightness
|
||||
else -> Axis.Volume
|
||||
}
|
||||
level = when (axis) {
|
||||
Axis.Brightness -> activity?.brightness() ?: 0.5f
|
||||
Axis.Volume -> audio?.mediaLevel() ?: 0f
|
||||
else -> 0f
|
||||
}
|
||||
seekFrom = position()
|
||||
seekTo = seekFrom
|
||||
}
|
||||
when (axis) {
|
||||
// A swipe the whole height of the screen goes from none to all.
|
||||
Axis.Brightness -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
activity?.setBrightness(level)
|
||||
onSwipe(Swipe.Brightness(level))
|
||||
}
|
||||
Axis.Volume -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
audio?.setMediaLevel(level)
|
||||
onSwipe(Swipe.Volume(level))
|
||||
}
|
||||
Axis.Seek -> {
|
||||
val length = duration()
|
||||
if (length <= 0.0) return@detectDragGestures
|
||||
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
|
||||
onSwipe(Swipe.Seek(seekFrom, seekTo))
|
||||
}
|
||||
Axis.Ignored -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's own brightness, or the screen's when the window has none. */
|
||||
private fun Activity.brightness(): Float {
|
||||
val own = window.attributes.screenBrightness
|
||||
if (own >= 0f) return own
|
||||
return runCatching {
|
||||
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
|
||||
}.getOrDefault(0.5f)
|
||||
}
|
||||
|
||||
private fun Activity.setBrightness(level: Float) {
|
||||
window.attributes = window.attributes.apply {
|
||||
// Never quite off: a black screen under a playing film looks broken.
|
||||
screenBrightness = level.coerceAtLeast(0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand brightness back to the system, as the player is left. */
|
||||
fun Activity.releaseBrightness() {
|
||||
window.attributes = window.attributes.apply {
|
||||
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioManager.mediaLevel(): Float {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
|
||||
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
|
||||
}
|
||||
|
||||
private fun AudioManager.setMediaLevel(level: Float) {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
// No flags: the bubble is the feedback, and the system's own volume panel
|
||||
// over the picture would be a second one.
|
||||
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
|
||||
}
|
||||
|
||||
/** The bubble in the middle of the picture while a swipe is under way. */
|
||||
@Composable
|
||||
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (swipe) {
|
||||
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
|
||||
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
|
||||
is Swipe.Seek -> {
|
||||
val delta = swipe.to - swipe.from
|
||||
Icon(
|
||||
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
(if (delta < 0) "−" else "+") + format(abs(delta)),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
|
||||
Box(Modifier.width(120.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { level },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = Color.White.copy(alpha = 0.25f),
|
||||
drawStopIndicator = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.narl.protonstream.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* The settings that are Android's alone.
|
||||
@@ -35,6 +36,31 @@ class SettingsStore(context: Context) {
|
||||
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||
|
||||
/** How the library was last sorted, as a `LibrarySort` name; null for the default. */
|
||||
var librarySort: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_SORT, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
|
||||
|
||||
/** The library's offer of posters and synopses was turned down. */
|
||||
var metadataOfferDismissed: Boolean
|
||||
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
|
||||
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
|
||||
|
||||
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
|
||||
var libraryFilter: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
|
||||
|
||||
/** Whether the library folds each franchise into one tile. */
|
||||
var libraryGrouped: Boolean
|
||||
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
||||
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
||||
|
||||
/** 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 {
|
||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||
@@ -44,10 +70,20 @@ class SettingsStore(context: Context) {
|
||||
|
||||
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||
|
||||
/** Load the file into memory, so no later read waits on the disk. */
|
||||
fun preload(context: Context) {
|
||||
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
|
||||
}
|
||||
|
||||
private const val NAME = "proton_stream_settings"
|
||||
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||
private const val KEY_LIBRARY_SORT = "library_sort"
|
||||
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
||||
private const val KEY_LIBRARY_FILTER = "library_filter"
|
||||
private const val KEY_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)) {
|
||||
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Optional. Signed in, watch history is kept in your own Drive so every device " +
|
||||
"resumes where another left off, and folders of your Drive can join the library.",
|
||||
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||
"Drive, so every device shows the same library and resumes where another left off, " +
|
||||
"and folders of your Drive can join the library.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -156,8 +157,8 @@ internal fun AccountCard(
|
||||
|
||||
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||
account.syncError?.let { return "Watch history did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
|
||||
account.syncError?.let { return "Did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||
val ago = when {
|
||||
minutes < 1 -> "just now"
|
||||
|
||||
@@ -25,7 +25,12 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import uniffi.pstr_android.SearchExtrasRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
@@ -38,20 +43,33 @@ import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.SignInOutcome
|
||||
import uniffi.pstr_android.SyncRecord
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
/** A crawl is running, which the banner offers to stop. */
|
||||
val crawling: Boolean = false,
|
||||
val query: String = "",
|
||||
/**
|
||||
* The whole library, whatever is searched for: the grid narrows to the
|
||||
* search through [arrangement], and a title opened from a search, or from
|
||||
* an episode it found, stays one the app can show.
|
||||
*/
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
/** What the search found besides titles; null with no search. */
|
||||
val searchExtras: SearchExtrasRecord? = null,
|
||||
/** The last few searches on this device, newest first. */
|
||||
val recentSearches: List<String> = emptyList(),
|
||||
/**
|
||||
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||
* [grouped], and the shelves above it. Null until the bridge has answered,
|
||||
* when the grid falls back to [titles] as they are.
|
||||
*/
|
||||
val arrangement: ArrangementRecord? = null,
|
||||
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||
val sort: LibrarySort = LibrarySort.NAME,
|
||||
val grouped: Boolean = true,
|
||||
val shares: List<ShareRecord> = emptyList(),
|
||||
@@ -64,6 +82,12 @@ data class AppUiState(
|
||||
),
|
||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
/**
|
||||
* What is running in the background right now, said in a banner for as
|
||||
* long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||
* minute-long crawl looked like a tap that did nothing.
|
||||
*/
|
||||
val activity: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
val account: AccountUiState = AccountUiState(),
|
||||
@@ -99,6 +123,21 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
private val mutableState = MutableStateFlow(AppUiState())
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||
/** Launch and coming back both ask; one crawl answers. */
|
||||
private var catchingUp = false
|
||||
|
||||
/**
|
||||
* Every banner line whose work is still running, oldest first. Touched on
|
||||
* the main thread only, as everything in [viewModelScope] is.
|
||||
*/
|
||||
private val activities = mutableListOf<Pair<Long, String>>()
|
||||
private var nextActivity = 0L
|
||||
|
||||
/** Crawls running now; main thread only, like [activities]. */
|
||||
private var crawlsRunning = 0
|
||||
|
||||
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
|
||||
private var crawlStopped = false
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
@@ -106,7 +145,22 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
// The arrangement first, so the first grid is drawn the way the viewer
|
||||
// left it rather than A–Z and then re-sorted under them.
|
||||
viewModelScope.launch {
|
||||
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).run {
|
||||
Triple(
|
||||
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
|
||||
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
|
||||
libraryGrouped,
|
||||
)
|
||||
}
|
||||
}
|
||||
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
|
||||
reload()
|
||||
}
|
||||
catchUp()
|
||||
viewModelScope.launch {
|
||||
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||
.getOrDefault(AccountState.SignedOut)
|
||||
@@ -116,12 +170,19 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
// and no upload.
|
||||
while (isActive) {
|
||||
syncWatchHistory()
|
||||
// A sync WatchSyncWorker ran while the app was away may have
|
||||
// brought shares this one has not crawled.
|
||||
catchUp()
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
|
||||
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
||||
@@ -131,10 +192,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder the grid, or fold or unfold its franchises. */
|
||||
/** Sort and fold the library, and keep both for the next launch. */
|
||||
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||
viewModelScope.launch { reloadLibrary(mutableState.value.query) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).apply {
|
||||
librarySort = sort.name
|
||||
libraryGrouped = grouped
|
||||
}
|
||||
}
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow the grid to some titles, and keep the choice for the next launch. */
|
||||
fun filter(filter: LibraryFilter) {
|
||||
mutableState.update { it.copy(filter = filter) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
@@ -142,16 +220,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the search being shown as one to offer again: called when a viewer
|
||||
* opens something it found. Nothing for an empty one.
|
||||
*/
|
||||
fun rememberSearch() {
|
||||
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().run {
|
||||
rememberSearch(query)
|
||||
recentSearches()
|
||||
}
|
||||
}
|
||||
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun forgetSearches() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
|
||||
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
|
||||
.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
busy("Refreshing the library…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(null) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||
* a new episode its stored guide cannot name. When everything already has
|
||||
* one the bridge asks nothing, so this is cheap after every crawl.
|
||||
*/
|
||||
private suspend fun matchAfterCrawl() {
|
||||
val settings = mutableState.value.metadataSettings
|
||||
if (settings.enabled && settings.ready) match(force = false, quiet = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Say [what] in the activity banner while [work] runs.
|
||||
*
|
||||
* The banner shows the newest line still running. Work overlaps — a sync
|
||||
* starts during a crawl and ends after it — so each line is removed by
|
||||
* itself rather than by putting back whatever was showing before, which
|
||||
* once brought back a finished crawl's line and spun it forever.
|
||||
*/
|
||||
private suspend fun busy(what: String, work: suspend () -> Unit) {
|
||||
val token = nextActivity++
|
||||
activities += token to what
|
||||
showActivity()
|
||||
try {
|
||||
work()
|
||||
} finally {
|
||||
activities.removeAll { it.first == token }
|
||||
showActivity()
|
||||
}
|
||||
}
|
||||
|
||||
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
|
||||
private suspend fun crawlShares(id: String?) {
|
||||
crawlsRunning++
|
||||
mutableState.update { it.copy(crawling = true) }
|
||||
try {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
|
||||
} finally {
|
||||
crawlsRunning--
|
||||
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the crawls running now. Shares already read stay in the library;
|
||||
* the one being read keeps what it had.
|
||||
*/
|
||||
fun stopCrawl() {
|
||||
crawlStopped = true
|
||||
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
private fun showActivity() {
|
||||
val newest = activities.lastOrNull()?.second
|
||||
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recrawl one share rather than the whole library.
|
||||
*
|
||||
@@ -162,11 +324,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
*/
|
||||
fun refreshShare(id: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||
busy("Reading $name…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,26 +344,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* changed.
|
||||
*/
|
||||
fun matchTitles(force: Boolean) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
viewModelScope.launch { match(force) }
|
||||
}
|
||||
|
||||
/**
|
||||
* With [quiet], a run nobody asked for says nothing when it found nothing
|
||||
* to do.
|
||||
*/
|
||||
private suspend fun match(force: Boolean, quiet: Boolean = false) {
|
||||
busy("Looking up titles…") {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
||||
.onSuccess { summary ->
|
||||
if (!quiet || !summary.isEmpty()) {
|
||||
mutableState.update { it.copy(message = describe(summary)) }
|
||||
}
|
||||
}
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
reloadNow()
|
||||
}
|
||||
|
||||
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
|
||||
|
||||
/**
|
||||
* Add a link, and return why it was refused, if it was.
|
||||
*
|
||||
* The link is opened before anything is stored, so the form can stay up
|
||||
* with the reason under it and a retry is a retry — not "already in the
|
||||
* library" for a share that never opened. Only the new share is crawled:
|
||||
* another share that fails to open must not make this one look broken.
|
||||
*/
|
||||
suspend fun addShare(name: String, url: String, password: String?): String? {
|
||||
val share = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
}
|
||||
}.getOrElse { return it.message ?: "That link could not be added" }
|
||||
reload()
|
||||
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl shares that are new here — added on this device or by sync — then
|
||||
* match them when enrichment is on.
|
||||
*/
|
||||
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
|
||||
if (ids.isEmpty()) return
|
||||
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||
val failed = mutableListOf<String>()
|
||||
crawlStopped = false
|
||||
for (id in ids) {
|
||||
if (crawlStopped) break
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||
busy("Reading $name…") {
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { failed += "$name: ${it.message}" }
|
||||
}
|
||||
}
|
||||
reloadNow()
|
||||
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||
// the share's row rather than in their face, and only news is said.
|
||||
if (failed.isNotEmpty() && announce) {
|
||||
mutableState.update { it.copy(message = failed.joinToString("\n")) }
|
||||
return
|
||||
}
|
||||
matchAfterCrawl()
|
||||
if (announce) {
|
||||
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
|
||||
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
|
||||
} else {
|
||||
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
|
||||
if (files > 0uL) {
|
||||
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addShare(name: String, url: String, password: String?) {
|
||||
/**
|
||||
* Crawl shares due a look: ones the catalog has nothing of — brought by a
|
||||
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
|
||||
* whose crawl never finished — and ones not crawled for six hours, so new
|
||||
* episodes turn up without a pull to refresh.
|
||||
*/
|
||||
fun catchUp() {
|
||||
if (catchingUp) return
|
||||
catchingUp = true
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
engine.crawl(null)
|
||||
}
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
reload()
|
||||
try {
|
||||
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||
.getOrDefault(emptyList())
|
||||
crawlAdded(ids, announce = false)
|
||||
} finally {
|
||||
catchingUp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +459,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
engine.crawl(id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a share something else. The library, history and downloads stay. */
|
||||
fun renameShare(id: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
|
||||
.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
@@ -354,7 +603,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
syncError = null,
|
||||
)
|
||||
}
|
||||
if (report.applied > 0u) reload()
|
||||
takeSynced(report)
|
||||
true
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -368,6 +617,35 @@ 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.listChanged ||
|
||||
report.sharesRemoved.isNotEmpty()
|
||||
) {
|
||||
reloadNow()
|
||||
}
|
||||
if (report.sharesAdded.isNotEmpty()) {
|
||||
crawlAdded(report.sharesAdded, announce = false)
|
||||
val count = report.sharesAdded.size
|
||||
mutableState.update {
|
||||
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After the player closes: where it stopped is what another device wants next. */
|
||||
fun playerClosed() {
|
||||
reload()
|
||||
@@ -376,16 +654,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
|
||||
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true) }
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val share = engine.addAccountFolder(name, volumeId, linkId)
|
||||
engine.crawl(share.id)
|
||||
}
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||
}.onSuccess { share ->
|
||||
reload()
|
||||
crawlAdded(listOf(share.id), announce = true)
|
||||
}.onFailure(::reportError)
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,11 +668,37 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||
val episodes = title.seasons.flatMap { it.episodes }
|
||||
/** Put a title on My List, or take it off. */
|
||||
fun setOnList(title: TitleRecord, on: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
|
||||
suspend fun moreLike(key: String): List<String> =
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
|
||||
|
||||
/** The keys of every title [facet] stands for, in library order. */
|
||||
suspend fun browse(facet: BrowseFacet): List<String> =
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
|
||||
.onFailure(::reportError)
|
||||
.getOrDefault(emptyList())
|
||||
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||
|
||||
/**
|
||||
* Some episodes seen or unseen — a season, or a whole title — with an
|
||||
* Undo; [what] names them in the message.
|
||||
*/
|
||||
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
|
||||
changeWatch(
|
||||
episodes,
|
||||
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
|
||||
episodes.filter { it.watched != watched },
|
||||
"$what marked ${if (watched) "watched" else "unwatched"}",
|
||||
) { engine, episode, before ->
|
||||
val duration = before?.durationSecs
|
||||
engine.saveWatchState(
|
||||
@@ -446,14 +746,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
snapshots.forEach { snapshot ->
|
||||
val state = snapshot.state
|
||||
engine.saveWatchState(
|
||||
snapshot.shareId,
|
||||
snapshot.linkId,
|
||||
state?.positionSecs ?: 0.0,
|
||||
state?.durationSecs,
|
||||
state?.watched ?: false,
|
||||
)
|
||||
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||
}
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
@@ -487,22 +780,29 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||
}
|
||||
|
||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
if (enabled) engine.matchTitles(false) else null
|
||||
/**
|
||||
* Store the enrichment settings, and return why not, if they were not.
|
||||
*
|
||||
* The page shows the new settings as soon as they are stored; the match
|
||||
* run that follows can take minutes and says so in the activity banner.
|
||||
*/
|
||||
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||
val stored = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// TMDB's is the only key: the provider's with TMDB chosen, and
|
||||
// film art's with AniList.
|
||||
if (apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
|
||||
}
|
||||
}.onSuccess { summary ->
|
||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
engine.metadataSettings()
|
||||
}
|
||||
}.getOrElse { return it.message ?: "The settings could not be saved" }
|
||||
mutableState.update { it.copy(metadataSettings = stored) }
|
||||
reloadNow()
|
||||
if (enabled) viewModelScope.launch { match(force = false) }
|
||||
return null
|
||||
}
|
||||
|
||||
fun reloadAfterMetadataChange() = reload()
|
||||
@@ -585,24 +885,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* at its duration where one is known, so the progress bar agrees with the
|
||||
* tick.
|
||||
*/
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
||||
engine.saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
if (watched) duration ?: 0.0 else 0.0,
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
/** One episode's tick, with the same Undo a season's has. */
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||
|
||||
fun removeAllOffline() {
|
||||
viewModelScope.launch {
|
||||
@@ -629,40 +913,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
engine.pruneOfflineFiles()
|
||||
val current = mutableState.value
|
||||
val query = current.query.takeIf(String::isNotBlank)
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(query),
|
||||
engine.arrangement(query, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
arrangement = result.arrangement,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch { reloadNow() }
|
||||
}
|
||||
|
||||
/** [reload], for a caller that goes on to read what it loaded. */
|
||||
private suspend fun reloadNow() {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
engine.pruneOfflineFiles()
|
||||
val current = mutableState.value
|
||||
val query = current.query.takeIf(String::isNotBlank)
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(null),
|
||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
arrangement = result.arrangement,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadLibrary(query: String) {
|
||||
@@ -671,10 +958,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val search = query.takeIf(String::isNotBlank)
|
||||
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
|
||||
Triple(
|
||||
engine.library(null),
|
||||
engine.arrangement(search, current.filter, current.sort, current.grouped),
|
||||
search?.let(engine::searchExtras),
|
||||
)
|
||||
}
|
||||
}.onSuccess { (titles, arrangement) ->
|
||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||
}.onSuccess { (titles, arrangement, extras) ->
|
||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -15,11 +35,22 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
@@ -93,24 +124,47 @@ internal fun Modifier.sharedArt(key: String): Modifier {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EmptyState(title: String, body: String) {
|
||||
internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Play on a title lands: the most recently played part-watched episode,
|
||||
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||
* Where Play on a title lands: what Continue watching offers
|
||||
* ([TitleRecord.continueIndex]), else the most recently played part-watched
|
||||
* episode, else the first unwatched one, else the first — the desktop's
|
||||
* `next_up`.
|
||||
*/
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||
continueIndex?.toInt()?.takeIf { it in playlist.indices }
|
||||
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
|
||||
internal fun formatRuntime(minutes: UInt): String {
|
||||
val hours = minutes / 60u
|
||||
val rest = minutes % 60u
|
||||
return when {
|
||||
hours == 0u -> "$rest min"
|
||||
rest == 0u -> "$hours h"
|
||||
else -> "$hours h $rest min"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatBytes(bytes: ULong): String {
|
||||
val value = bytes.toDouble()
|
||||
return when {
|
||||
@@ -122,11 +176,52 @@ internal fun formatBytes(bytes: ULong): String {
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||
* Download episodes, asking first when it is more than one: a season or a
|
||||
* show is tens of gigabytes, and one tap should not fill a phone. The
|
||||
* question says how many and how big. Call it in composition; it draws the
|
||||
* dialog where it is called and returns what starts the download.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
@Composable
|
||||
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
|
||||
asking?.let { (episodes, what) ->
|
||||
val bytes = episodes.sumOf { it.size ?: 0uL }
|
||||
AlertDialog(
|
||||
onDismissRequest = { asking = null },
|
||||
title = { Text("Download ${episodes.size} episodes of $what?") },
|
||||
text = {
|
||||
Text(
|
||||
"They are kept on this device for watching without a connection" +
|
||||
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
|
||||
". Delete them from Downloads.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
asking = null
|
||||
DownloadCoordinator.enqueue(context, episodes)
|
||||
}) { Text("Download") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
return remember(context) {
|
||||
{ episodes, what ->
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline)
|
||||
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
|
||||
* [waitingFor], what it is really waiting on.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
|
||||
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
|
||||
?: download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
@@ -135,6 +230,32 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
|
||||
?.let { "${formatBytes(it.toULong())}/s" },
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* Why a queued download is not running, when it is not merely a free slot:
|
||||
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
|
||||
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
|
||||
* changes — the download's own updates are the clock.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberQueueReason(key: Any?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(key) {
|
||||
val network = context.getSystemService(ConnectivityManager::class.java)
|
||||
when {
|
||||
network?.activeNetwork == null -> "Waiting for a connection"
|
||||
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode's own name: the provider's, else the one the filename gave it.
|
||||
* `null` rather than the filename, which is a release group's tag and the
|
||||
* episode's numbering again.
|
||||
*/
|
||||
internal fun EpisodeRecord.shownName(): String? = providerName ?: detail.takeIf { it != name && it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* What an episode row is headed with: "1. Mother and Children".
|
||||
*
|
||||
@@ -143,7 +264,7 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
|
||||
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
||||
*/
|
||||
internal fun EpisodeRecord.heading(position: Int): String {
|
||||
val named = providerName ?: detail.takeIf { it != name }
|
||||
val named = shownName()
|
||||
val number = number?.toInt()
|
||||
return when {
|
||||
named != null && number != null -> "$number. $named"
|
||||
@@ -152,3 +273,86 @@ internal fun EpisodeRecord.heading(position: Int): String {
|
||||
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the title is filed under — a genre — as a small rounded label.
|
||||
* The desktop's `widgets::chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||
* [GenreChip] with a dot in front: the accent while more is coming, muted once
|
||||
* it is all out. The desktop's `widgets::status_chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier
|
||||
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||
* The desktop's `widgets::spec_badge`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpecBadge(text: String) {
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = ink,
|
||||
modifier = Modifier
|
||||
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* My List's button: a plus to put the title on it, a tick once it is, which
|
||||
* takes it off again. [compact] is the icon alone, for the banner's row, where
|
||||
* a third labelled button does not fit a phone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
|
||||
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
|
||||
val label = if (onList) "Remove from My List" else "Add to My List"
|
||||
if (compact) {
|
||||
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
|
||||
Icon(icon, contentDescription = label)
|
||||
}
|
||||
} else {
|
||||
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("My List")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package io.narl.protonstream.ui
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -40,7 +42,7 @@ import androidx.work.WorkManager
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onDeletePartial: (RetainedDownload) -> Unit,
|
||||
padding: PaddingValues,
|
||||
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||
onOpen: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
|
||||
)
|
||||
}
|
||||
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||
SavedRow(file, group.title, onDelete = { deleting = file })
|
||||
SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +142,7 @@ internal fun DownloadsScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
DangerButton(onClick = {
|
||||
deleting = null
|
||||
onRemove(file)
|
||||
}) { Text("Delete") }
|
||||
@@ -160,9 +164,23 @@ private fun SectionHeading(title: String, caption: String? = null) {
|
||||
|
||||
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||
@Composable
|
||||
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
|
||||
private fun SavedRow(
|
||||
file: OfflineRecord,
|
||||
title: TitleRecord?,
|
||||
onDelete: () -> Unit,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
) {
|
||||
val episode = file.episode
|
||||
// What a download is for: tapping it plays it, from where it was left,
|
||||
// without going back through the library to find it.
|
||||
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
|
||||
ListItem(
|
||||
modifier = if (title != null && index >= 0) {
|
||||
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
leadingContent = {
|
||||
RemoteArtwork(
|
||||
episode?.stillUrl ?: title?.backdropUrl,
|
||||
@@ -179,8 +197,15 @@ private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> U
|
||||
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
Row {
|
||||
title?.let {
|
||||
IconButton(onClick = { onOpen(it) }) {
|
||||
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
@@ -204,7 +229,7 @@ private fun PartialRow(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(downloadStatusLine(download))
|
||||
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -19,8 +41,13 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import java.time.LocalDate
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
@@ -35,8 +62,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -46,6 +71,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -61,6 +87,7 @@ import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
@@ -70,6 +97,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -81,11 +111,21 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import uniffi.pstr_android.FacetCountRecord
|
||||
import uniffi.pstr_android.SearchExtrasRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
import uniffi.pstr_android.airingCountdown
|
||||
import uniffi.pstr_android.facetKind
|
||||
|
||||
/**
|
||||
* How many shows the continue-watching shelf holds.
|
||||
@@ -96,11 +136,51 @@ import uniffi.pstr_android.TitleType
|
||||
private const val CONTINUE_WATCHING_MAX = 12
|
||||
|
||||
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||
private data class Resumable(
|
||||
val title: TitleRecord,
|
||||
val episode: EpisodeRecord,
|
||||
val index: Int,
|
||||
/** When the title was last played, which orders the shelf. */
|
||||
val lastPlayed: Long,
|
||||
)
|
||||
|
||||
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
|
||||
private data class Tile(val title: TitleRecord, val folded: Int)
|
||||
|
||||
/** The desktop's names for the filters, from `browse::Filter::label`. */
|
||||
internal fun LibraryFilter.label(): String = when (this) {
|
||||
LibraryFilter.ALL -> "All"
|
||||
LibraryFilter.SERIES -> "Series"
|
||||
LibraryFilter.FILMS -> "Films"
|
||||
LibraryFilter.WATCHING -> "Watching"
|
||||
LibraryFilter.UNWATCHED -> "Not started"
|
||||
LibraryFilter.DOWNLOADED -> "Downloaded"
|
||||
LibraryFilter.MY_LIST -> "My List"
|
||||
}
|
||||
|
||||
/** The offer of posters and synopses, and its two answers. */
|
||||
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun MetadataOfferCard(offer: MetadataOffer) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Text("Add posters and synopses?", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Turning it on in Settings sends the titles in your library — not your files, and " +
|
||||
"nothing about what you have watched — to AniList or TMDB.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, end = 8.dp),
|
||||
)
|
||||
Row(Modifier.align(Alignment.End)) {
|
||||
QuietButton(onClick = offer.onDismiss) { Text("Not now") }
|
||||
QuietButton(onClick = offer.onOpenSettings) { Text("Open settings") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row above the grid: titles that share a director, a studio or a genre. */
|
||||
private data class Shelf(val label: String, val titles: List<TitleRecord>)
|
||||
|
||||
@@ -118,22 +198,35 @@ internal fun LibraryScreen(
|
||||
onMatchChanged: () -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||
onAddShare: () -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||
/** Open every title sharing a genre, studio or director a search named. */
|
||||
onBrowse: (BrowseFacet) -> Unit = {},
|
||||
onForgetSearches: () -> Unit = {},
|
||||
) {
|
||||
val download = rememberDownload()
|
||||
val context = LocalContext.current
|
||||
// Posters and synopses, offered once there is a library to put them on
|
||||
// and never turned on unasked: switching them on sends title names out.
|
||||
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
|
||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
// Most recently played first, one episode per show: a shelf that lists four
|
||||
// episodes of the same series is a shelf with room for nothing else.
|
||||
// What to go on with is the engine's call (`continueIndex`): the
|
||||
// part-watched episode, or the one after the episode finished last.
|
||||
val resumable = remember(state.titles) {
|
||||
state.titles.mapNotNull { title ->
|
||||
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
|
||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
playlist.withIndex()
|
||||
.filter { it.value.resumeAt != null && !it.value.watched }
|
||||
.maxByOrNull { it.value.lastPlayed }
|
||||
?.let { Resumable(title, it.value, it.index) }
|
||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||
playlist.getOrNull(index)?.let { episode ->
|
||||
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
|
||||
}
|
||||
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||
}
|
||||
// Search is an icon until it is wanted: a permanent field above the
|
||||
// library spent a band of every visit on something used in few of them.
|
||||
@@ -143,66 +236,107 @@ internal fun LibraryScreen(
|
||||
onSearch("")
|
||||
}
|
||||
BackHandler(enabled = searching, onBack = closeSearch)
|
||||
// The large title folds into the bar as the grid scrolls, and comes back
|
||||
// only at the top: the page says what it is on arrival, then gets out of
|
||||
// the way.
|
||||
// The banner turns through these. Worked out here rather than in the grid,
|
||||
// because whether there is one decides the shape of the whole page.
|
||||
val featured = remember(state.titles, resumable) { featured(state.titles, resumable) }
|
||||
// With a banner, the bar lies over it, clear, and fills in as the banner
|
||||
// scrolls away; without one, the large title folds into the bar as the
|
||||
// grid scrolls and comes back only at the top.
|
||||
val overBanner = !searching && !state.loading && state.query.isBlank() && featured.isNotEmpty()
|
||||
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||
if (searching) {
|
||||
SearchBar(state.query, onSearch, closeSearch)
|
||||
} else {
|
||||
LargeTopAppBar(
|
||||
title = { Text("Library") },
|
||||
actions = {
|
||||
IconButton(onClick = { searching = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||
}
|
||||
ArrangeMenu(state.sort, state.grouped, onArrange)
|
||||
// Pull to refresh is the gesture; the icon stays for a
|
||||
// viewer who does not know it.
|
||||
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
colors = pageBarColors(),
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
val grid = rememberLazyGridState()
|
||||
val actions: @Composable RowScope.() -> Unit = {
|
||||
IconButton(onClick = { searching = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.refreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.weight(1f).padding(padding),
|
||||
) {
|
||||
when {
|
||||
state.loading -> LibrarySkeleton()
|
||||
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
||||
"Nothing matches",
|
||||
"No title in the library is called “${state.query.trim()}”.",
|
||||
)
|
||||
state.titles.isEmpty() -> EmptyState(
|
||||
"Your library is empty",
|
||||
"Add a Proton Drive public link under Shares, then refresh.",
|
||||
)
|
||||
else -> LibraryGrid(
|
||||
state,
|
||||
resumable,
|
||||
onResume,
|
||||
onTitle,
|
||||
onTitleMenu = { menuFor = it },
|
||||
onResumeMenu = { menuResume = it },
|
||||
ArrangeMenu(state.sort, state.grouped, onArrange)
|
||||
// Pull to refresh is the gesture; the icon stays for a viewer who
|
||||
// does not know it.
|
||||
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
|
||||
if (searching) {
|
||||
SearchBar(state.query, onSearch, closeSearch)
|
||||
} else if (!overBanner) {
|
||||
LargeTopAppBar(
|
||||
title = { Text("Library") },
|
||||
actions = actions,
|
||||
colors = pageBarColors(),
|
||||
scrollBehavior = scroll,
|
||||
)
|
||||
}
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.refreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.weight(1f).padding(padding),
|
||||
) {
|
||||
when {
|
||||
state.loading -> LibrarySkeleton()
|
||||
// Empty and with the field open: the last few searches.
|
||||
searching && state.query.isBlank() && state.recentSearches.isNotEmpty() -> RecentSearches(
|
||||
state.recentSearches,
|
||||
onPick = onSearch,
|
||||
onForget = onForgetSearches,
|
||||
)
|
||||
state.query.isNotBlank() &&
|
||||
state.arrangement?.tiles?.isEmpty() == true &&
|
||||
state.searchExtras?.isEmpty() != false -> EmptyState(
|
||||
"Nothing matches",
|
||||
"Nothing in the library is called “${state.query.trim()}”.",
|
||||
)
|
||||
// A share just added: say it is being looked through, rather
|
||||
// than offer to add the one that is already filling the page.
|
||||
state.titles.isEmpty() && state.activity != null -> EmptyState(
|
||||
"Looking through the share",
|
||||
"${state.activity} Its titles appear here once it has been listed.",
|
||||
)
|
||||
state.titles.isEmpty() -> EmptyState(
|
||||
"Your library is empty",
|
||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||
action = "Add a share" to onAddShare,
|
||||
)
|
||||
else -> LibraryGrid(
|
||||
state,
|
||||
resumable,
|
||||
onResume,
|
||||
onTitle,
|
||||
onTitleMenu = { menuFor = it },
|
||||
onResumeMenu = { menuResume = it },
|
||||
onFilter = onFilter,
|
||||
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
|
||||
null
|
||||
} else {
|
||||
MetadataOffer(onOpenSettings) {
|
||||
offerDismissed = true
|
||||
SettingsStore(context).metadataOfferDismissed = true
|
||||
}
|
||||
},
|
||||
featured = if (overBanner) featured else emptyList(),
|
||||
grid = grid,
|
||||
onSetOnList = onSetOnList,
|
||||
onBrowse = onBrowse,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overBanner) BarOverBanner(grid, actions)
|
||||
}
|
||||
menuFor?.let { title ->
|
||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||
onResume(title, nextUpIndex(playlist))
|
||||
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
||||
}
|
||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||
MenuRow(
|
||||
if (title.onList) Icons.Default.Check else Icons.Default.Add,
|
||||
if (title.onList) "Remove from My List" else "Add to My List",
|
||||
) { onSetOnList(title, !title.onList) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
||||
MenuRow(
|
||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||
@@ -384,6 +518,12 @@ private fun LibraryGrid(
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onTitleMenu: (TitleRecord) -> Unit,
|
||||
onResumeMenu: (Resumable) -> Unit,
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
metadataOffer: MetadataOffer? = null,
|
||||
featured: List<TitleRecord> = emptyList(),
|
||||
grid: LazyGridState = rememberLazyGridState(),
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||
onBrowse: (BrowseFacet) -> Unit = {},
|
||||
) {
|
||||
// The arrangement names titles by key; before it has arrived, the titles
|
||||
// as they came are the grid.
|
||||
@@ -402,24 +542,31 @@ private fun LibraryGrid(
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(108.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = grid,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||
// No room above a banner: it starts under the status bar, and the bar
|
||||
// lies over it.
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = if (featured.isEmpty()) 8.dp else 0.dp, bottom = 24.dp),
|
||||
) {
|
||||
if (featured.isNotEmpty()) {
|
||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||
FeaturedBanner(
|
||||
featured,
|
||||
resumable,
|
||||
onPlay = onResume,
|
||||
onOpen = onTitle,
|
||||
onSetOnList = onSetOnList,
|
||||
modifier = Modifier.bleed(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Above the grid, and only when there is something in it: what a viewer
|
||||
// opening the app wants is almost always the thing they were part way
|
||||
// through. Inside the grid rather than pinned over it, so the shelf
|
||||
// scrolls away with the rest.
|
||||
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||
featured?.let { title ->
|
||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||
FeaturedBanner(
|
||||
title,
|
||||
resume = resumable.firstOrNull { it.title.key == title.key },
|
||||
onPlay = { onResume(title, it) },
|
||||
onOpen = { onTitle(title) },
|
||||
)
|
||||
}
|
||||
if (metadataOffer != null && state.query.isBlank()) {
|
||||
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
|
||||
}
|
||||
if (state.query.isBlank() && resumable.isNotEmpty()) {
|
||||
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
|
||||
@@ -441,7 +588,10 @@ private fun LibraryGrid(
|
||||
}
|
||||
}
|
||||
if (state.query.isBlank()) {
|
||||
shelves.forEach { shelf ->
|
||||
// Only over the whole library: under a filter, a shelf of titles
|
||||
// the grid below is hiding reads as the filter not working. The
|
||||
// bridge leaves them out too; this covers the frame before it.
|
||||
shelves.takeIf { state.filter == LibraryFilter.ALL }.orEmpty().forEach { shelf ->
|
||||
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
|
||||
@@ -456,15 +606,70 @@ private fun LibraryGrid(
|
||||
onLongClick = { onTitleMenu(title) },
|
||||
modifier = Modifier.width(SHELF_TILE_WIDTH),
|
||||
shared = false,
|
||||
// On a shelf, an airing show says when its
|
||||
// next episode is out.
|
||||
badge = title.badge ?: title.countdown(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(LibraryFilter.entries, key = { it.name }) { filter ->
|
||||
FilterChip(
|
||||
selected = filter == state.filter,
|
||||
onClick = { onFilter(filter) },
|
||||
label = { Text(filter.label()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tiles.isEmpty() && state.filter != LibraryFilter.ALL) {
|
||||
item(key = "filtered-out", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
"Nothing in the library fits that filter.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// What the search found besides titles, above the titles it found.
|
||||
state.searchExtras?.takeIf { state.query.isNotBlank() }?.let { extras ->
|
||||
facetChips("People and studios", extras.people, onBrowse)
|
||||
facetChips("Genres", extras.genres, onBrowse)
|
||||
if (extras.episodes.isNotEmpty()) {
|
||||
item(key = "search-episodes", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
Text("Episodes", style = MaterialTheme.typography.titleMedium)
|
||||
extras.episodes.forEach { hit ->
|
||||
val title = byKey[hit.titleKey] ?: return@forEach
|
||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
val index = playlist.indexOfFirst { it.linkId == hit.linkId }
|
||||
if (index < 0) return@forEach
|
||||
ListItem(
|
||||
headlineContent = { Text(hit.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Text("${title.displayName} · ${hit.label}", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
leadingContent = { Icon(Icons.Default.PlayArrow, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClickLabel = "Play") { onResume(title, index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tiles.isNotEmpty() && !extras.isEmpty()) {
|
||||
item(key = "search-titles", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text("Titles", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,6 +684,55 @@ private fun LibraryGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a search found nothing besides titles. */
|
||||
internal fun SearchExtrasRecord.isEmpty(): Boolean = episodes.isEmpty() && people.isEmpty() && genres.isEmpty()
|
||||
|
||||
/** A row of the genres, studios or people a search named, each opening its page. */
|
||||
private fun LazyGridScope.facetChips(heading: String, facets: List<FacetCountRecord>, onBrowse: (BrowseFacet) -> Unit) {
|
||||
if (facets.isEmpty()) return
|
||||
item(key = "search-$heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
Text(heading, style = MaterialTheme.typography.titleMedium)
|
||||
FlowRow(
|
||||
Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
facets.forEach { found ->
|
||||
GenreChip("${found.facet.label} · ${found.count}") { onBrowse(found.facet) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The last few searches, under an empty search field. */
|
||||
@Composable
|
||||
private fun RecentSearches(recent: List<String>, onPick: (String) -> Unit, onForget: () -> Unit) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
item(key = "heading") {
|
||||
Text(
|
||||
"Recent searches",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(recent, key = { it }) { query ->
|
||||
ListItem(
|
||||
headlineContent = { Text(query) },
|
||||
leadingContent = { Icon(Icons.Default.History, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { onPick(query) },
|
||||
)
|
||||
}
|
||||
item(key = "forget") {
|
||||
QuietButton(onClick = onForget, modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Text("Clear recent searches")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid's shape before the catalog has answered: posters and their two
|
||||
* lines of text as blocks, so the page does not jump when the titles land.
|
||||
@@ -511,73 +765,251 @@ private val SHELF_TILE_WIDTH = 112.dp
|
||||
/** Enough placeholder tiles to fill a tablet's first screen. */
|
||||
private const val SKELETON_TILES = 18
|
||||
|
||||
/** How many titles the banner turns through: the desktop's `FEATURED`. */
|
||||
private const val FEATURED_MAX = 5
|
||||
|
||||
/** How long each title holds the banner: the desktop's `HOLD`. */
|
||||
private const val HOLD_MS = 8_000L
|
||||
|
||||
/** How long one title takes to give way to the next: the desktop's `TURN`. */
|
||||
private const val TURN_MS = 600
|
||||
|
||||
/**
|
||||
* The title the banner shows: the one watched last, or with nothing part
|
||||
* watched, one of the titles the provider has a backdrop for, turning over
|
||||
* once a day — the desktop's `featured`.
|
||||
* The titles the banner turns through: the one watched last first, then titles
|
||||
* the provider has a backdrop for, starting from a different one each day —
|
||||
* the desktop's `featured`.
|
||||
*/
|
||||
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
|
||||
resumable.firstOrNull()?.let { return it.title }
|
||||
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
|
||||
val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
|
||||
val pictured = titles.filter { it.backdropUrl != null }
|
||||
if (pictured.isEmpty()) return null
|
||||
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
|
||||
if (pictured.isEmpty()) return picked
|
||||
val start = (LocalDate.now().toEpochDay() % pictured.size).toInt()
|
||||
for (offset in pictured.indices) {
|
||||
if (picked.size == FEATURED_MAX) break
|
||||
val title = pictured[(start + offset) % pictured.size]
|
||||
if (picked.none { it.key == title.key }) picked += title
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
/** Large art behind the name, rating, genres and three lines of synopsis. */
|
||||
/**
|
||||
* Wider than the space it is given by [horizontal] on each side: for the
|
||||
* banner, which runs edge to edge inside a grid with a margin.
|
||||
*/
|
||||
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
|
||||
val extra = horizontal.roundToPx() * 2
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
|
||||
)
|
||||
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar over the banner: clear at the top, with white icons over the
|
||||
* banner's own scrim, and filling in to the page's bar as the banner scrolls
|
||||
* away — at which point it is the page's bar, title and all.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
|
||||
private fun BarOverBanner(grid: LazyGridState, actions: @Composable RowScope.() -> Unit) {
|
||||
val reveal = with(LocalDensity.current) { BAR_REVEAL.toPx() }
|
||||
val solid by remember(grid) {
|
||||
derivedStateOf {
|
||||
if (grid.firstVisibleItemIndex > 0) 1f else (grid.firstVisibleItemScrollOffset / reveal).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val ink = lerp(Color.White, scheme.onSurface, solid)
|
||||
TopAppBar(
|
||||
title = { Text("Library", modifier = Modifier.graphicsLayer { alpha = solid }) },
|
||||
actions = actions,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = scheme.surfaceContainer.copy(alpha = solid),
|
||||
titleContentColor = ink,
|
||||
actionIconContentColor = ink,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** How far the banner scrolls before the bar over it is solid. */
|
||||
private val BAR_REVEAL = 280.dp
|
||||
|
||||
/**
|
||||
* The banner at the top of the library: one title at a time, edge to edge
|
||||
* under the status bar, turning to the next every [HOLD_MS] unless a finger is
|
||||
* on it. Each title's veil is tinted with its own picture's colour.
|
||||
*/
|
||||
@Composable
|
||||
private fun FeaturedBanner(
|
||||
titles: List<TitleRecord>,
|
||||
resumable: List<Resumable>,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pager = rememberPagerState { titles.size }
|
||||
val dragged by pager.interactionSource.collectIsDraggedAsState()
|
||||
if (titles.size > 1 && !dragged) {
|
||||
// Keyed on the settled page, not the current one: the current page
|
||||
// flips half way through a turn, and restarting this effect there
|
||||
// cancelled the turn and left the banner stuck between two titles.
|
||||
LaunchedEffect(pager.settledPage, titles.size) {
|
||||
delay(HOLD_MS)
|
||||
pager.animateScrollToPage((pager.settledPage + 1) % titles.size, animationSpec = tween(TURN_MS))
|
||||
}
|
||||
}
|
||||
// Most of a phone's first screen, but never so much of a tablet's that
|
||||
// nothing under it shows.
|
||||
val screenHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
|
||||
val height = (screenHeight * 0.62f).coerceIn(360.dp, 620.dp)
|
||||
Box(modifier.fillMaxWidth().height(height)) {
|
||||
HorizontalPager(pager, Modifier.fillMaxSize(), key = { titles[it].key }) { page ->
|
||||
val title = titles[page]
|
||||
FeaturedPage(
|
||||
title,
|
||||
resumable.firstOrNull { it.title.key == title.key },
|
||||
onPlay = { onPlay(title, it) },
|
||||
onOpen = { onOpen(title) },
|
||||
onSetOnList = { onSetOnList(title, it) },
|
||||
)
|
||||
}
|
||||
if (titles.size > 1) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Row(
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
val shown = index == pager.currentPage
|
||||
val width by animateDpAsState(if (shown) 18.dp else 6.dp, label = "dot")
|
||||
Box(
|
||||
Modifier
|
||||
.size(width, 6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = if (shown) 1f else 0.4f))
|
||||
.clickable(onClickLabel = "Show ${title.displayName}") {
|
||||
scope.launch { pager.animateScrollToPage(index, animationSpec = tween(TURN_MS)) }
|
||||
}
|
||||
.semantics { selected = shown },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
|
||||
@Composable
|
||||
private fun FeaturedPage(
|
||||
title: TitleRecord,
|
||||
resume: Resumable?,
|
||||
onPlay: (Int) -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
onSetOnList: (Boolean) -> Unit,
|
||||
) {
|
||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.displayName,
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
)
|
||||
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val veil = moodVeil(mood)
|
||||
val ink = MaterialTheme.colorScheme.onBackground
|
||||
// A logo is lettering made for dark art, white as often as not; over a
|
||||
// light palette's veil it is gone, so the light palette keeps the name.
|
||||
val dark = background.luminance() < 0.5f
|
||||
Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
|
||||
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
|
||||
Brush.verticalGradient(
|
||||
// Dark under the status bar and the bar's white icons…
|
||||
0f to Color.Black.copy(alpha = 0.5f),
|
||||
0.22f to Color.Transparent,
|
||||
// …and the picture's own colour under the text, ending in
|
||||
// the page it hands over to.
|
||||
0.45f to veil.copy(alpha = 0.35f),
|
||||
0.78f to veil.copy(alpha = 0.92f),
|
||||
1f to background,
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
||||
Text(
|
||||
title.displayName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||
) {
|
||||
if (dark) {
|
||||
RemoteLogo(
|
||||
title.logoUrl,
|
||||
title.displayName,
|
||||
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
|
||||
) { HeroName(title.displayName, ink) }
|
||||
} else {
|
||||
HeroName(title.displayName, ink)
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||
).joinToString(" · ").ifEmpty { title.caption() },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
color = ink.copy(alpha = 0.8f),
|
||||
)
|
||||
title.overview?.let {
|
||||
Text(
|
||||
it.trim(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
maxLines = 3,
|
||||
color = ink.copy(alpha = 0.8f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.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)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (resume != null) "Resume" else "Play")
|
||||
Text(if (resume?.episode?.resumeAt != null) "Resume" else "Play")
|
||||
}
|
||||
TonalButton(onClick = onOpen) {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("More info")
|
||||
}
|
||||
ListButton(title.onList, onSetOnList, compact = true)
|
||||
}
|
||||
val episode = resume?.episode
|
||||
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
|
||||
if (progress != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
|
||||
AccentProgress(
|
||||
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier.width(120.dp),
|
||||
)
|
||||
episode.timeLeft()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = ink.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
TonalButton(onClick = onOpen) { Text("More info") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroName(name: String, ink: Color) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = ink,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
||||
@Composable
|
||||
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
@@ -585,14 +1017,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
||||
Modifier
|
||||
.width(220.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this episode"),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||
// Named by the text under it; read as well, the name came twice.
|
||||
RemoteArtwork(
|
||||
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||
entry.title.displayName,
|
||||
Modifier.fillMaxSize(),
|
||||
Modifier.fillMaxSize().clearAndSetSemantics {},
|
||||
fallback = entry.episode.thumbnailSource,
|
||||
maxEdge = 220.dp,
|
||||
)
|
||||
entry.episode.progress?.let { progress ->
|
||||
AccentProgress(
|
||||
@@ -609,7 +1043,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||
if (entry.episode.resumeAt == null) {
|
||||
"Next: ${entry.episode.label}"
|
||||
} else {
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
@@ -634,11 +1072,12 @@ private fun PosterTile(
|
||||
modifier: Modifier = Modifier,
|
||||
folded: Int = 0,
|
||||
shared: Boolean = true,
|
||||
badge: String? = title.badge,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
|
||||
) {
|
||||
Box {
|
||||
RemoteArtwork(
|
||||
@@ -647,8 +1086,11 @@ private fun PosterTile(
|
||||
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clearAndSetSemantics {},
|
||||
fallback = title.thumbnailSource,
|
||||
// The tile's height, at the widest a grid column gets.
|
||||
maxEdge = 280.dp,
|
||||
)
|
||||
if (folded > 0) {
|
||||
Text(
|
||||
@@ -660,6 +1102,21 @@ private fun PosterTile(
|
||||
.padding(6.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "and $folded more" },
|
||||
)
|
||||
}
|
||||
if (badge != null) {
|
||||
Text(
|
||||
badge,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(6.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -704,3 +1161,79 @@ private fun EpisodeRecord.timeLeft(): String? {
|
||||
val minutes = ((at / fraction - at) / 60).toInt()
|
||||
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
||||
}
|
||||
|
||||
/** `Ep 8 · in 2 d` for a show whose next episode is still to air. */
|
||||
internal fun TitleRecord.countdown(now: Long = System.currentTimeMillis() / 1000): String? {
|
||||
val at = nextAiringAt?.takeIf { it > now } ?: return null
|
||||
val episode = nextEpisode ?: return null
|
||||
return airingCountdown(episode, at, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every title sharing a genre, a studio or a director: what a chip on a title
|
||||
* page opens. The desktop's `library::browse`.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun BrowseScreen(
|
||||
facet: BrowseFacet,
|
||||
titles: List<TitleRecord>,
|
||||
onBack: () -> Unit,
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
padding: PaddingValues,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
facetKind(facet),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(facet.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
colors = pageBarColors(),
|
||||
)
|
||||
if (titles.isEmpty()) {
|
||||
// A recrawl or a new match took the last one away.
|
||||
Box(Modifier.padding(padding)) {
|
||||
EmptyState(facet.label, "Nothing in the library has this any more.")
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(108.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||
) {
|
||||
item(key = "count", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
if (titles.size == 1) "1 title" else "${titles.size} titles",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
items(titles, key = { it.key }) { title ->
|
||||
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitle(title) }, shared = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The name a [BrowseFacet] stands for: the chip's text and its page's heading. */
|
||||
internal val BrowseFacet.label: String
|
||||
get() = when (this) {
|
||||
is BrowseFacet.Genre -> name
|
||||
is BrowseFacet.Studio -> name
|
||||
is BrowseFacet.Person -> name
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
@@ -51,6 +62,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -65,7 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.work.WorkManager
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
@@ -98,7 +112,6 @@ private enum class Destination(val label: String, val icon: ImageVector) {
|
||||
@Composable
|
||||
fun ProtonStreamApp(
|
||||
playerHost: LibmpvHost? = null,
|
||||
inPictureInPicture: Boolean = false,
|
||||
shareLink: String? = null,
|
||||
onShareLinkTaken: () -> Unit = {},
|
||||
model: AppViewModel = viewModel(
|
||||
@@ -123,6 +136,29 @@ fun ProtonStreamApp(
|
||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||
// into the video they had just left.
|
||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||
var addingShare by remember { mutableStateOf(false) }
|
||||
// What a genre, studio or director chip opened, over the library pane.
|
||||
// As its two parts, which survive process recreation where the record
|
||||
// would not.
|
||||
var browseKind by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var browseName by rememberSaveable { mutableStateOf("") }
|
||||
// The title it was opened from, which is where back goes, as on the
|
||||
// desktop.
|
||||
var browseFrom by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val browsing = when (browseKind) {
|
||||
"genre" -> BrowseFacet.Genre(browseName)
|
||||
"studio" -> BrowseFacet.Studio(browseName)
|
||||
"person" -> BrowseFacet.Person(browseName)
|
||||
else -> null
|
||||
}
|
||||
val browse = { facet: BrowseFacet ->
|
||||
browseKind = when (facet) {
|
||||
is BrowseFacet.Genre -> "genre"
|
||||
is BrowseFacet.Studio -> "studio"
|
||||
is BrowseFacet.Person -> "person"
|
||||
}
|
||||
browseName = facet.label
|
||||
}
|
||||
|
||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||
val playing = playingTitleKey
|
||||
@@ -160,7 +196,7 @@ fun ProtonStreamApp(
|
||||
Destination.entries.forEach { item ->
|
||||
item(
|
||||
selected = destination == item,
|
||||
onClick = { destination = item; selectedTitleKey = null },
|
||||
onClick = { destination = item; selectedTitleKey = null; browseKind = null },
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
colors = navigation,
|
||||
@@ -199,16 +235,34 @@ fun ProtonStreamApp(
|
||||
// library stays in view while a title is open, so moving
|
||||
// between titles is one tap rather than back and in again.
|
||||
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val twoPane = maxWidth >= TWO_PANE_WIDTH
|
||||
val browsed by produceState(emptyList<String>(), browsing, state.titles) {
|
||||
value = browsing?.let { model.browse(it) }.orEmpty()
|
||||
}
|
||||
val libraryPane = @Composable {
|
||||
LibraryScreen(
|
||||
browsing?.let { facet ->
|
||||
BrowseScreen(
|
||||
facet,
|
||||
browsed.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
|
||||
onBack = {
|
||||
browseKind = null
|
||||
browseFrom?.let { selectedTitleKey = it }
|
||||
},
|
||||
onTitle = { selectedTitleKey = it.key },
|
||||
padding = body,
|
||||
)
|
||||
} ?: LibraryScreen(
|
||||
state,
|
||||
model::search,
|
||||
{ title, index ->
|
||||
// A search that led somewhere is one
|
||||
// worth offering again.
|
||||
model.rememberSearch()
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
{ selectedTitleKey = it.key },
|
||||
{ model.rememberSearch(); selectedTitleKey = it.key },
|
||||
model::refresh,
|
||||
model::setTitleWatched,
|
||||
model::forgetPosition,
|
||||
@@ -216,9 +270,25 @@ fun ProtonStreamApp(
|
||||
onMatchChanged = model::reloadAfterMetadataChange,
|
||||
onError = model::reportError,
|
||||
onArrange = model::arrange,
|
||||
onAddShare = {
|
||||
destination = Destination.Shares
|
||||
addingShare = true
|
||||
},
|
||||
onOpenSettings = { destination = Destination.Settings },
|
||||
onFilter = model::filter,
|
||||
onSetOnList = model::setOnList,
|
||||
onBrowse = { facet ->
|
||||
model.rememberSearch()
|
||||
browse(facet)
|
||||
browseFrom = null
|
||||
},
|
||||
onForgetSearches = model::forgetSearches,
|
||||
)
|
||||
}
|
||||
val titlePane = @Composable { selected: TitleRecord ->
|
||||
val similar by produceState(emptyList<String>(), selected.key, state.titles) {
|
||||
value = model.moreLike(selected.key)
|
||||
}
|
||||
TitleScreen(
|
||||
selected,
|
||||
playerHost != null,
|
||||
@@ -232,13 +302,23 @@ fun ProtonStreamApp(
|
||||
model::reloadAfterMetadataChange,
|
||||
model::setWatched,
|
||||
body,
|
||||
onMarkEpisodes = model::markEpisodes,
|
||||
franchise = selected.franchise.mapNotNull { key ->
|
||||
state.titles.firstOrNull { it.key == key }
|
||||
},
|
||||
onOpenTitle = { selectedTitleKey = it.key },
|
||||
onSetOnList = model::setOnList,
|
||||
moreLike = similar.mapNotNull { key -> state.titles.firstOrNull { it.key == key } },
|
||||
onBrowse = { facet ->
|
||||
browse(facet)
|
||||
browseFrom = selected.key
|
||||
// On a phone the list is under the title;
|
||||
// the page it opens is the library pane's.
|
||||
if (!twoPane) selectedTitleKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
if (maxWidth >= TWO_PANE_WIDTH) {
|
||||
if (twoPane) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.weight(0.45f)) { libraryPane() }
|
||||
VerticalDivider()
|
||||
@@ -263,12 +343,21 @@ fun ProtonStreamApp(
|
||||
}
|
||||
Destination.Shares -> SharesScreen(
|
||||
state.shares,
|
||||
model::addShare,
|
||||
{ name, url, password ->
|
||||
// The first share is the library: watch it fill
|
||||
// rather than stay on a form that has done its job.
|
||||
val first = state.titles.isEmpty()
|
||||
model.addShare(name, url, password).also { refusal ->
|
||||
if (refusal == null && first) destination = Destination.Library
|
||||
}
|
||||
},
|
||||
model::repairShare,
|
||||
model::refreshShare,
|
||||
model::removeShare,
|
||||
shareLink,
|
||||
onShareLinkTaken,
|
||||
// The empty library's button opens the form as a
|
||||
// pasted link would, only with nothing in it.
|
||||
shareLink ?: "".takeIf { addingShare },
|
||||
{ addingShare = false; onShareLinkTaken() },
|
||||
body,
|
||||
account = state.account,
|
||||
accountActions = AccountActions(
|
||||
@@ -282,6 +371,7 @@ fun ProtonStreamApp(
|
||||
addFolder = model::addAccountFolder,
|
||||
onError = model::reportError,
|
||||
),
|
||||
onRename = model::renameShare,
|
||||
)
|
||||
Destination.History -> HistoryScreen(
|
||||
state,
|
||||
@@ -301,6 +391,15 @@ fun ProtonStreamApp(
|
||||
model::resumeDownload,
|
||||
model::deletePartial,
|
||||
body,
|
||||
onPlay = { title, index ->
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
onOpen = { title ->
|
||||
destination = Destination.Library
|
||||
selectedTitleKey = title.key
|
||||
},
|
||||
)
|
||||
Destination.Settings -> SettingsScreen(
|
||||
state,
|
||||
@@ -327,22 +426,28 @@ fun ProtonStreamApp(
|
||||
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
}
|
||||
ActivityBanner(
|
||||
state.activity,
|
||||
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn over the scaffold rather than instead of it. The player owns the
|
||||
// whole window while it is up — it is a screen, not a dialog over one, which
|
||||
// is what lets it draw into the cutout and hand the right window to
|
||||
// Picture-in-Picture — but the page behind it stays composed, so leaving the
|
||||
// player finds the title page where it was rather than rebuilt from the top.
|
||||
// is what lets it draw into the cutout — but the page behind it stays
|
||||
// composed, so leaving the player finds the title page where it was rather
|
||||
// than rebuilt from the top.
|
||||
playing?.let { request ->
|
||||
PlayerScreen(
|
||||
title = request.title,
|
||||
episodes = request.episodes,
|
||||
index = request.index,
|
||||
host = playerHost,
|
||||
inPictureInPicture = inPictureInPicture,
|
||||
minimized = playerMinimized,
|
||||
onIndexChange = { playingIndex = it },
|
||||
onSaveProgress = model::saveProgress,
|
||||
@@ -400,3 +505,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") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -24,21 +27,30 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.core.graphics.scale
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.moodVeil as sharedMoodVeil
|
||||
import uniffi.pstr_android.pictureMood as sharedPictureMood
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -76,9 +88,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
||||
*
|
||||
* [fallback] is used only when there is no provider artwork or it fails to
|
||||
* load, which — with metadata off — is the common case rather than the rare
|
||||
* one. Until either answers, the box pulses as a skeleton; when neither has
|
||||
* one. Until either answers, the box shimmers as a skeleton; when neither has
|
||||
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||
* initials when [labelled] is false, for art with the name already over it.
|
||||
*
|
||||
* Art that arrives fades in over the skeleton rather than replacing it in one
|
||||
* frame, which across a grid reads as flicker. Art already decoded is drawn
|
||||
* at once: a tile scrolled back into view has nothing to arrive.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteArtwork(
|
||||
@@ -87,26 +103,201 @@ internal fun RemoteArtwork(
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: ThumbnailSource? = null,
|
||||
labelled: Boolean = true,
|
||||
maxEdge: Dp? = null,
|
||||
onMood: ((Color) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||
// 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(alreadyDecoded(url, fallback, edge)) }
|
||||
LaunchedEffect(url, fallback, enabled) {
|
||||
if (!enabled) {
|
||||
image = ArtworkLoad.Missing
|
||||
return@LaunchedEffect
|
||||
}
|
||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||
?: ArtworkLoad.Missing
|
||||
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
|
||||
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
|
||||
if (bitmap != null && onMood != null) {
|
||||
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
|
||||
}
|
||||
}
|
||||
when (val loaded = image) {
|
||||
ArtworkLoad.Pending -> Skeleton(modifier)
|
||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
||||
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
|
||||
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
|
||||
when (loaded) {
|
||||
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
|
||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
|
||||
is ArtworkLoad.Loaded -> Image(
|
||||
loaded.image,
|
||||
description,
|
||||
Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the decoded cache already holds for this art, so it skips the fade. */
|
||||
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
|
||||
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
|
||||
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
|
||||
}
|
||||
|
||||
/**
|
||||
* How long art takes to fade in over its skeleton: the desktop's
|
||||
* `motion::ARRIVE`.
|
||||
*/
|
||||
private const val ARRIVE_MS = 240
|
||||
|
||||
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
|
||||
private const val SHIMMER_MS = 1_400
|
||||
|
||||
/**
|
||||
* A title's art across the top of a page: its backdrop, or with none, its
|
||||
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
|
||||
* the poster's light rather than a blown-up slice of it. The desktop's
|
||||
* `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode,
|
||||
* stretched, is what softens it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
|
||||
val wash = title.backdropUrl == null && title.posterUrl != null
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.displayName,
|
||||
if (wash) modifier.blur(WASH_BLUR) else modifier,
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
maxEdge = if (wash) WASH_EDGE else null,
|
||||
onMood = onMood,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a picture leaves behind — the shared `appearance::mood`, over the
|
||||
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
|
||||
* banner paging back to a title does not work it out again.
|
||||
*/
|
||||
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
|
||||
moods.get(key)?.let { return Color(it) }
|
||||
val argb = withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
val small = bitmap.scale(MOOD_EDGE, MOOD_EDGE)
|
||||
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
|
||||
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
|
||||
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
|
||||
}.getOrNull()
|
||||
} ?: return null
|
||||
moods.put(key, argb)
|
||||
return Color(argb)
|
||||
}
|
||||
|
||||
private val moods = LruCache<String, Int>(64)
|
||||
|
||||
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
|
||||
private const val MOOD_EDGE = 24
|
||||
|
||||
/**
|
||||
* [background] tinted by a picture's [mood], for the veils over that picture —
|
||||
* the shared `appearance::mood_veil`. [background] itself until the mood is
|
||||
* known.
|
||||
*/
|
||||
@Composable
|
||||
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
|
||||
val light = background.luminance() > 0.5f
|
||||
val target = remember(mood, background) {
|
||||
mood?.let {
|
||||
runCatching {
|
||||
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
|
||||
}.getOrNull()
|
||||
} ?: background
|
||||
}
|
||||
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
|
||||
}
|
||||
|
||||
private val WASH_EDGE = 48.dp
|
||||
private val WASH_BLUR = 24.dp
|
||||
|
||||
/**
|
||||
* A title's logo — its name as lettering — in place of [fallback], once it has
|
||||
* loaded. [fallback] stands until then, and for good when there is none, so a
|
||||
* title without a logo looks as it always did and one with a logo only gains.
|
||||
*
|
||||
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
|
||||
* logo to a square, and the padding would otherwise decide its size and place.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteLogo(
|
||||
url: String?,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, enabled) {
|
||||
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
|
||||
}
|
||||
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
|
||||
when (loaded) {
|
||||
null -> fallback()
|
||||
else -> Image(
|
||||
loaded,
|
||||
description,
|
||||
modifier,
|
||||
alignment = Alignment.BottomStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
|
||||
val requested = url ?: return null
|
||||
val key = "logo:$requested"
|
||||
decoded.get(key)?.let { return it }
|
||||
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
|
||||
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
|
||||
}
|
||||
|
||||
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
|
||||
private fun trimTransparent(bitmap: Bitmap): Bitmap {
|
||||
if (!bitmap.hasAlpha()) return bitmap
|
||||
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
|
||||
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
|
||||
* row-major image of [width] that are not (nearly) transparent; `null` when
|
||||
* none are. Below the threshold is anti-aliasing haze, not lettering.
|
||||
*/
|
||||
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
|
||||
var left = Int.MAX_VALUE
|
||||
var top = Int.MAX_VALUE
|
||||
var right = -1
|
||||
var bottom = -1
|
||||
for (index in pixels.indices) {
|
||||
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
|
||||
val x = index % width
|
||||
val y = index / width
|
||||
if (x < left) left = x
|
||||
if (x > right) right = x
|
||||
if (y < top) top = y
|
||||
if (y > bottom) bottom = y
|
||||
}
|
||||
return if (right < 0) null else intArrayOf(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private const val VISIBLE_ALPHA = 8
|
||||
|
||||
/** A logo is drawn a few hundred pixels wide at most. */
|
||||
private const val LOGO_EDGE = 800
|
||||
|
||||
private sealed interface ArtworkLoad {
|
||||
data object Pending : ArtworkLoad
|
||||
data object Missing : ArtworkLoad
|
||||
@@ -123,20 +314,38 @@ private sealed interface ArtworkLoad {
|
||||
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Something being loaded: the surface tint, breathing.
|
||||
* Something being loaded: the surface tint, with a soft highlight sweeping
|
||||
* across it on a diagonal.
|
||||
*
|
||||
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||
* says what is coming and where, and settles the layout before it arrives.
|
||||
* The sweep is measured in the tile's own width, so neighbouring tiles of one
|
||||
* size shimmer in step, as one surface.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = 0.55f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||
label = "skeleton alpha",
|
||||
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = -1f,
|
||||
targetValue = 2f,
|
||||
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
|
||||
label = "skeleton sweep",
|
||||
)
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
|
||||
Box(
|
||||
modifier.drawBehind {
|
||||
drawRect(base)
|
||||
val centre = size.width * sweep
|
||||
val half = size.width * 0.6f
|
||||
drawRect(
|
||||
Brush.linearGradient(
|
||||
listOf(Color.Transparent, highlight, Color.Transparent),
|
||||
start = Offset(centre - half, 0f),
|
||||
end = Offset(centre + half, size.height),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,8 +395,8 @@ internal fun initials(name: String): String {
|
||||
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||
* shows costs no second download.
|
||||
*/
|
||||
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
||||
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
||||
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val uri = URI(requested)
|
||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||
@@ -197,7 +406,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
val cached = directory.resolve("$digest.img")
|
||||
val fresh = !cached.isFile
|
||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
|
||||
val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" }
|
||||
if (fresh) {
|
||||
// Written only once it has decoded. Caching the bytes first is
|
||||
// what turns one hostile poster into a crash on every launch
|
||||
@@ -210,7 +419,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
}
|
||||
bitmap
|
||||
}.getOrNull()?.also { decoded.put(requested, it) }
|
||||
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,21 +433,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
* share or a session that has to be reopened is transient, and the next
|
||||
* composition should try again.
|
||||
*/
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
|
||||
val file = source ?: return null
|
||||
val key = file.key
|
||||
decoded.get(key)?.let { return it }
|
||||
decoded.get(sized(key, edge))?.let { return it }
|
||||
if (key in missing) return null
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||
}
|
||||
val bytes = fetched.getOrElse { return null }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
|
||||
if (bitmap == null) {
|
||||
missing.add(key)
|
||||
return null
|
||||
}
|
||||
decoded.put(key, bitmap)
|
||||
decoded.put(sized(key, edge), bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@@ -257,28 +466,43 @@ private fun download(uri: URI): ByteArray {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode within a bound the display can actually use.
|
||||
* Decode with its longest edge at most [edge] pixels.
|
||||
*
|
||||
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||
* costs no allocation and settles a sample size before anything is committed.
|
||||
*
|
||||
* Sampling halves, so it stops while the image is still at least [edge]; the
|
||||
* decoder scales the rest of the way, which a poster sampled straight to under
|
||||
* its tile would show as blur.
|
||||
*/
|
||||
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
||||
private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (longest <= 0) return null
|
||||
var sample = 1
|
||||
while (longest / sample > MAX_ART_EDGE) sample *= 2
|
||||
while (longest / (sample * 2) >= edge) sample *= 2
|
||||
val sampled = longest / sample
|
||||
return BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
if (sampled > edge) {
|
||||
inScaled = true
|
||||
inDensity = sampled
|
||||
inTargetDensity = edge
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The decoded cache's key for one image at one size. */
|
||||
private fun sized(key: String, edge: Int) = "$key#$edge"
|
||||
|
||||
/**
|
||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||
*
|
||||
@@ -315,7 +539,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
|
||||
* is a full-resolution decode per item.
|
||||
*/
|
||||
private val decoded = object : LruCache<String, Bitmap>(
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
|
||||
) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
@@ -59,12 +59,14 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
@@ -86,7 +88,8 @@ import io.narl.protonstream.ui.theme.AccentProgress
|
||||
@Composable
|
||||
internal fun SettingsScreen(
|
||||
state: AppUiState,
|
||||
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||
/** Resolves to why the settings were not stored, or null once they are. */
|
||||
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
onMatchAgain: () -> Unit,
|
||||
onClearCache: () -> Unit,
|
||||
onRemoveAllOffline: () -> Unit,
|
||||
@@ -102,6 +105,7 @@ internal fun SettingsScreen(
|
||||
val settings = remember { SettingsStore(context) }
|
||||
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
|
||||
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||
// Playback preferences are the shared store's, not this app's: they are the
|
||||
@@ -205,6 +209,15 @@ internal fun SettingsScreen(
|
||||
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) {
|
||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||
@@ -315,7 +328,7 @@ internal fun SettingsScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||
DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||
)
|
||||
@@ -327,10 +340,7 @@ internal fun SettingsScreen(
|
||||
MetadataSettingsDialog(
|
||||
current = state.metadataSettings,
|
||||
onDismiss = { showMetadata = false },
|
||||
onSave = { enabled, provider, language, key ->
|
||||
onSaveMetadata(enabled, provider, language, key)
|
||||
showMetadata = false
|
||||
},
|
||||
onSave = onSaveMetadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -411,57 +421,95 @@ private fun SettingAction(
|
||||
private fun MetadataSettingsDialog(
|
||||
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
) {
|
||||
var enabled by remember { mutableStateOf(current.enabled) }
|
||||
var provider by remember { mutableStateOf(current.provider) }
|
||||
var language by remember { mutableStateOf(current.language) }
|
||||
var apiKey by remember { mutableStateOf("") }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
|
||||
val forgets = current.enabled && (!enabled || provider != current.provider)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Metadata enrichment") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
|
||||
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
|
||||
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MetadataProvider.entries.forEach { option ->
|
||||
listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option ->
|
||||
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
|
||||
else "Film and television; requires a free TMDB API key.",
|
||||
if (provider == MetadataProvider.ANI_LIST) {
|
||||
"Anime, and television from TVmaze; no account or API key required. " +
|
||||
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
|
||||
} else {
|
||||
"Film and television; requires a free TMDB API key."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (provider == MetadataProvider.TMDB) {
|
||||
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
}
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
label = {
|
||||
Text(
|
||||
when {
|
||||
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
|
||||
current.ready -> "TMDB API key (leave blank to keep)"
|
||||
else -> "TMDB API key"
|
||||
},
|
||||
)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||
// Said before Save rather than after: what is forgotten
|
||||
// includes matches chosen by hand, which no look-up redoes.
|
||||
if (forgets) {
|
||||
Text(
|
||||
"Saving forgets every poster, description and match stored now, " +
|
||||
"including ones you chose by hand.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
||||
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
||||
) { Text("Save") }
|
||||
enabled = !needsKey && !saving,
|
||||
onClick = {
|
||||
saving = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
|
||||
saving = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||
MetadataProvider.ANI_LIST -> "AniList"
|
||||
MetadataProvider.TMDB -> "TMDB"
|
||||
MetadataProvider.TV_MAZE -> "TVmaze"
|
||||
}
|
||||
|
||||
private data class LegalDocument(val title: String, val assetPath: String)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -25,13 +30,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -45,12 +54,15 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FolderShared
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
internal fun SharesScreen(
|
||||
shares: List<ShareRecord>,
|
||||
onAdd: (String, String, String?) -> Unit,
|
||||
/** Resolves to why the link was refused, or null once it is added. */
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
onRepair: (String, String, String?) -> Unit,
|
||||
onRefresh: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
@@ -59,8 +71,10 @@ internal fun SharesScreen(
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
onRename: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
var browsing by remember { mutableStateOf(false) }
|
||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
@@ -97,6 +111,7 @@ internal fun SharesScreen(
|
||||
onRefresh = { onRefresh(share.id) },
|
||||
onRepair = { repairing = share },
|
||||
onRemove = { removing = share },
|
||||
onRename = { renaming = share },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +150,21 @@ internal fun SharesScreen(
|
||||
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||
)
|
||||
}
|
||||
renaming?.let { share ->
|
||||
var name by remember(share.id) { mutableStateOf(share.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Rename ${share.name}") },
|
||||
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
|
||||
confirmButton = {
|
||||
AccentButton(enabled = name.isNotBlank(), onClick = {
|
||||
renaming = null
|
||||
onRename(share.id, name.trim())
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
removing?.let { share ->
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
@@ -148,7 +178,7 @@ internal fun SharesScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||
DangerButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||
)
|
||||
@@ -161,24 +191,58 @@ internal fun SharesScreen(
|
||||
* Three differently styled controls beside the name squeezed it into a column
|
||||
* a word wide; one icon leaves the row its width.
|
||||
*/
|
||||
/** "120 files · crawled 2 h ago", or that it has not been yet. */
|
||||
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
|
||||
val at = share.crawledAt ?: return "Not crawled yet"
|
||||
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
|
||||
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
|
||||
}
|
||||
|
||||
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
|
||||
internal fun ago(at: Long, now: Long): String {
|
||||
val seconds = (now - at).coerceAtLeast(0)
|
||||
return when {
|
||||
seconds < 60 -> "just now"
|
||||
seconds < 3_600 -> "${seconds / 60} min ago"
|
||||
seconds < 86_400 -> "${seconds / 3_600} h ago"
|
||||
seconds < 172_800 -> "1 day ago"
|
||||
else -> "${seconds / 86_400} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareRow(
|
||||
share: ShareRecord,
|
||||
onRefresh: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onRename: () -> Unit = {},
|
||||
) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password, stored securely"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
Text(crawlLine(share), style = MaterialTheme.typography.bodySmall)
|
||||
// Until a crawl succeeds: a link revoked upstream otherwise
|
||||
// just stops growing.
|
||||
share.crawlError?.let {
|
||||
Text(
|
||||
"Last crawl failed: $it",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
@@ -200,6 +264,11 @@ private fun ShareRow(
|
||||
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||
onClick = { menu = false; onRefresh() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||
onClick = { menu = false; onRename() },
|
||||
)
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
@@ -277,32 +346,55 @@ private fun RepairShareDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a link. The form stays up while the link is opened, and with the reason
|
||||
* under it when the link is refused, so fixing a typo is a retry rather than
|
||||
* starting over.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddShareDialog(
|
||||
initialUrl: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String, String, String?) -> Unit,
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||
var showLink by remember { mutableStateOf(false) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { if (!adding) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Add Proton Drive share") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
url,
|
||||
{ url = it },
|
||||
label = { Text("Public share URL") },
|
||||
label = { Text("Share link") },
|
||||
singleLine = true,
|
||||
// The URL fragment is a secret. Password input disables
|
||||
// IME learning/suggestions while normal long-press paste
|
||||
// remains available.
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
// remains available; the eye shows what was pasted.
|
||||
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showLink = !showLink }) {
|
||||
Icon(
|
||||
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showLink) "Hide link" else "Show link",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{ name = it },
|
||||
label = { Text("Name (optional)") },
|
||||
supportingText = { Text("Defaults to the shared folder's name") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
password,
|
||||
@@ -312,14 +404,30 @@ private fun AddShareDialog(
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
if (adding) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text("Opening the link…")
|
||||
}
|
||||
} else {
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
||||
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||
) { Text("Add") }
|
||||
onClick = {
|
||||
adding = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onAdd(name.trim(), url.trim(), password)
|
||||
adding = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = url.isNotBlank() && !adding,
|
||||
) { Text(if (adding) "Adding…" else "Add") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,20 @@ import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -30,6 +38,8 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,14 +48,20 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.RemoveDone
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -68,12 +84,16 @@ import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import io.narl.protonstream.ui.theme.solid
|
||||
import androidx.compose.foundation.border
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import uniffi.pstr_android.CastRecord
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
@@ -81,10 +101,13 @@ import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.MatchRecord
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Replay
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
@@ -97,11 +120,19 @@ internal fun TitleScreen(
|
||||
onMetadataChanged: () -> Unit,
|
||||
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||
padding: PaddingValues,
|
||||
/** Several episodes seen or unseen at once, named for the message. */
|
||||
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
|
||||
/** Every title of its franchise, in release order, this one included. */
|
||||
franchise: List<TitleRecord> = emptyList(),
|
||||
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||
onSetOnList: (TitleRecord, Boolean) -> Unit = { _, _ -> },
|
||||
/** The titles most like it, best first. */
|
||||
moreLike: List<TitleRecord> = emptyList(),
|
||||
/** Open every title sharing a genre, studio or director. */
|
||||
onBrowse: (BrowseFacet) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val download = rememberDownload()
|
||||
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||
|
||||
@@ -122,7 +153,7 @@ internal fun TitleScreen(
|
||||
// Display order across seasons: what previous/next and autoplay walk, and
|
||||
// the same order the desktop client uses.
|
||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
|
||||
val upNext = playlist.getOrNull(nextUp)
|
||||
// One season on screen at a time, opened on the one Play would land in: a
|
||||
// forty-episode show as stacked sections is one long scroll to the part
|
||||
@@ -138,7 +169,11 @@ internal fun TitleScreen(
|
||||
// without a trip to the Downloads tab.
|
||||
val work by WorkManager.getInstance(context)
|
||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||
// Decoded off the main thread: WorkManager reports progress several times
|
||||
// a second while a season downloads, and each report re-reads every record.
|
||||
val downloads by produceState(emptyList<RetainedDownload>(), work) {
|
||||
value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() }
|
||||
}
|
||||
|
||||
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||
// button and synopsis lines two thousand pixels wide are read by turning
|
||||
@@ -158,8 +193,12 @@ internal fun TitleScreen(
|
||||
) {
|
||||
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||
val list = rememberLazyListState()
|
||||
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
|
||||
val veil = moodVeil(mood)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = list,
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -167,37 +206,46 @@ internal fun TitleScreen(
|
||||
// Edge to edge, fading into the page: the art is the first thing
|
||||
// the page says, and a framed thumbnail with a margin round it read
|
||||
// as one card among the many below it.
|
||||
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.displayName,
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) {
|
||||
// At half the speed of the page, so the picture stays
|
||||
// behind the page rather than being part of it.
|
||||
Backdrop(
|
||||
title,
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f
|
||||
},
|
||||
onMood = { mood = it },
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.45f to Color.Transparent,
|
||||
0.4f to Color.Transparent,
|
||||
// The picture's own colour on the way into the
|
||||
// page, as on the library's banner.
|
||||
0.75f to veil.copy(alpha = 0.55f),
|
||||
1f to MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "heading") {
|
||||
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||
val heading = @Composable {
|
||||
Text(title.displayName, style = MaterialTheme.typography.displayLarge)
|
||||
}
|
||||
// A light page keeps the name: a clear logo is lettering
|
||||
// made for dark art, white as often as not.
|
||||
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
|
||||
RemoteLogo(
|
||||
title.logoUrl,
|
||||
title.displayName,
|
||||
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 4.dp),
|
||||
heading,
|
||||
)
|
||||
} else {
|
||||
heading()
|
||||
}
|
||||
// The other names it goes by: the share's when the shown one
|
||||
// is the provider's, and the original-language one.
|
||||
listOfNotNull(
|
||||
@@ -210,6 +258,8 @@ internal fun TitleScreen(
|
||||
listOfNotNull(
|
||||
title.formatLabel,
|
||||
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
|
||||
// A series' running time is its episodes', which each row says.
|
||||
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||
).joinToString(" · "),
|
||||
@@ -217,19 +267,20 @@ internal fun TitleScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
// Who made it, and whether more is coming — the desktop's
|
||||
// line of facts, on a line of its own so neither wraps.
|
||||
listOfNotNull(
|
||||
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
||||
title.directors.firstOrNull()?.let { "dir. $it" },
|
||||
airingNote(title),
|
||||
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
// Where the title stands, then what the files are rather
|
||||
// than what the title is.
|
||||
val status = statusText(title)
|
||||
if (title.quality.isNotEmpty() || status != null) {
|
||||
Row(
|
||||
Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
status?.let { (text, live) ->
|
||||
StatusChip(text, live, Modifier.padding(end = 4.dp))
|
||||
}
|
||||
title.quality.forEach { SpecBadge(it) }
|
||||
}
|
||||
}
|
||||
// The one thing the page is for, full width and naming what it
|
||||
// will play: "Resume" alone left the viewer guessing which
|
||||
@@ -258,6 +309,13 @@ internal fun TitleScreen(
|
||||
// three button styles used to wrap over three lines and bury
|
||||
// the episodes under them.
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
TitleAction(
|
||||
if (title.onList) Icons.Default.Check else Icons.Default.Add,
|
||||
"My List",
|
||||
Modifier.weight(1f).semantics {
|
||||
stateDescription = if (title.onList) "On My List" else "Not on My List"
|
||||
},
|
||||
) { onSetOnList(title, !title.onList) }
|
||||
// Only where it would do something different: an unstarted
|
||||
// show already starts at the beginning.
|
||||
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||
@@ -266,7 +324,7 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||
DownloadCoordinator.enqueue(context, playlist)
|
||||
download(playlist, title.displayName)
|
||||
}
|
||||
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes
|
||||
@@ -295,15 +353,63 @@ internal fun TitleScreen(
|
||||
.clickable { overviewOpen = !overviewOpen },
|
||||
)
|
||||
}
|
||||
// In the browser: the app itself never requests it.
|
||||
title.trailerUrl?.let { url ->
|
||||
QuietButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||
}.onFailure(onPreferenceError)
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Movie, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Trailer")
|
||||
}
|
||||
}
|
||||
// Genres as chips, then the provider's tags quieter after
|
||||
// them: a tag is its community describing it, not what it
|
||||
// is filed as.
|
||||
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
|
||||
if (title.genres.isNotEmpty()) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
title.genres.forEach { genre -> GenreChip(genre) { onBrowse(BrowseFacet.Genre(genre)) } }
|
||||
}
|
||||
}
|
||||
// Who made it, as chips that open everything else by them:
|
||||
// the desktop's credits row.
|
||||
val studios = title.studios.take(2)
|
||||
val director = title.directors.take(1)
|
||||
if (studios.isNotEmpty() || director.isNotEmpty()) {
|
||||
FlowRow(
|
||||
Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
listOf(
|
||||
Triple("Studio", studios) { name: String -> BrowseFacet.Studio(name) },
|
||||
Triple("Director", director) { name: String -> BrowseFacet.Person(name) },
|
||||
).filter { it.second.isNotEmpty() }.forEach { (label, names, facet) ->
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
names.forEach { name -> GenreChip(name) { onBrowse(facet(name)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (title.tags.isNotEmpty()) {
|
||||
Text(
|
||||
(title.genres + title.tags.take(4)).joinToString(" · "),
|
||||
title.tags.take(4).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
// Said plainly, because it changes what a re-match will do: a
|
||||
@@ -342,17 +448,34 @@ internal fun TitleScreen(
|
||||
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
label = { Text(entry.label) },
|
||||
)
|
||||
if (title.seasons.size > SEASON_CHIPS) {
|
||||
// A long-running show's row of chips is a scroll of
|
||||
// its own; past a handful it is a list to pick from.
|
||||
SeasonList(title.seasons, seasonIndex, { seasonIndex = it }, Modifier.weight(1f))
|
||||
} else {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// By position: two seasons can share a label, and a
|
||||
// repeated key is a crash in a lazy list.
|
||||
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
label = { Text(entry.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
season?.let {
|
||||
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||
// For someone joining the show partway, who would
|
||||
// otherwise tick every earlier episode one by one.
|
||||
val seen = it.episodes.all(EpisodeRecord::watched)
|
||||
IconButton(onClick = { onMarkEpisodes(it.episodes, !seen, "${title.displayName} ${it.label}") }) {
|
||||
Icon(
|
||||
if (seen) Icons.Default.RemoveDone else Icons.Default.DoneAll,
|
||||
contentDescription = "Mark ${it.label} ${if (seen) "unwatched" else "watched"}",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { download(it.episodes, "${title.displayName} ${it.label}") }) {
|
||||
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||
}
|
||||
}
|
||||
@@ -374,11 +497,61 @@ internal fun TitleScreen(
|
||||
onPause = { DownloadCoordinator.pause(context, it) },
|
||||
onResume = { DownloadCoordinator.resume(context, it) },
|
||||
onSetWatched = { onSetWatched(episode, it) },
|
||||
onWatchedUpTo = {
|
||||
val upTo = playlist.indexOfFirst { it.linkId == episode.linkId }
|
||||
onMarkEpisodes(
|
||||
playlist.take(upTo + 1),
|
||||
true,
|
||||
"${title.displayName} up to ${episode.label}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (title.cast.isNotEmpty()) {
|
||||
item(key = "cast") {
|
||||
Column(Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
"Cast",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = side + 16.dp),
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
|
||||
) {
|
||||
// Unkeyed: two characters can share a name.
|
||||
items(title.cast) { member ->
|
||||
CastTile(member) { actor -> onBrowse(BrowseFacet.Person(actor)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Under the episodes rather than over them: where to go once this
|
||||
// one is done, not what the page is for.
|
||||
if (moreLike.isNotEmpty()) {
|
||||
item(key = "more-like-this") {
|
||||
Column(Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
"More like this",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = side + 16.dp),
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
|
||||
) {
|
||||
items(moreLike, key = { it.key }) { other ->
|
||||
FranchiseTile(other, here = false) { onOpenTitle(other) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack)
|
||||
}
|
||||
if (showMatch) {
|
||||
ChangeMatchDialog(
|
||||
@@ -390,9 +563,108 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** How fast the backdrop moves against the page scrolling over it. */
|
||||
private const val PARALLAX = 0.5f
|
||||
|
||||
/**
|
||||
* One part of the franchise: its cover, what it is and when. This title's own
|
||||
* is marked rather than left out, so the row says where it sits in the story.
|
||||
* The bar over the title page: only the back button while the backdrop is on
|
||||
* screen, then — once the backdrop has scrolled away — a solid bar with the
|
||||
* title's name, so the page still says whose episodes these are.
|
||||
*/
|
||||
@Composable
|
||||
private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack: () -> Unit) {
|
||||
val reach = with(LocalDensity.current) { TITLE_BAR.toPx() }
|
||||
val solid by remember(list, backdrop) {
|
||||
derivedStateOf {
|
||||
if (list.firstVisibleItemIndex > 0) {
|
||||
1f
|
||||
} else {
|
||||
((list.firstVisibleItemScrollOffset - (backdrop - 2 * reach)) / reach).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(scheme.surfaceContainer.copy(alpha = solid))
|
||||
.statusBarsPadding()
|
||||
.height(TITLE_BAR),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
// A disc behind the arrow over the picture, gone once the bar
|
||||
// is there to sit on.
|
||||
.background(Color.Black.copy(alpha = 0.4f * (1f - solid))),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to library",
|
||||
tint = lerp(Color.White, scheme.onSurface, solid),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(end = 16.dp).graphicsLayer { alpha = solid },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TITLE_BAR = 56.dp
|
||||
|
||||
/**
|
||||
* One character: a round portrait over their name and who voices them. A voice
|
||||
* actor opens every title in the library they are in.
|
||||
*/
|
||||
@Composable
|
||||
private fun CastTile(member: CastRecord, onActor: (String) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(88.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.then(
|
||||
member.actor?.let { actor ->
|
||||
Modifier.clickable(onClickLabel = "Browse $actor") { onActor(actor) }
|
||||
} ?: Modifier,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
RemoteArtwork(
|
||||
member.characterImage ?: member.actorImage,
|
||||
member.character,
|
||||
Modifier.size(76.dp).clip(CircleShape),
|
||||
labelled = false,
|
||||
)
|
||||
Text(
|
||||
member.character,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
member.actor?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One part of the franchise, or one title like this one: its cover, what it is
|
||||
* and when. In the franchise row this title's own is marked rather than left
|
||||
* out, so the row says where it sits in the story.
|
||||
*/
|
||||
@Composable
|
||||
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
|
||||
@@ -439,17 +711,51 @@ private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
|
||||
* no date yet, else nothing.
|
||||
* What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
|
||||
* and whether more is coming. The desktop's `status_text`.
|
||||
*/
|
||||
private fun airingNote(title: TitleRecord): String? {
|
||||
internal fun statusText(title: TitleRecord): Pair<String, Boolean>? {
|
||||
val at = title.nextAiringAt
|
||||
val episode = title.nextEpisode
|
||||
if (at != null && episode != null) {
|
||||
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
|
||||
val next = at != null && episode != null
|
||||
val label = title.status ?: (if (next || title.airing) "Airing" else return null)
|
||||
val live = next || title.airing
|
||||
if (at == null || episode == null) return label to live
|
||||
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||
return "$label · episode $episode " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) to live
|
||||
}
|
||||
|
||||
/** The most seasons picked from a row of chips; past it, a list. The desktop's `SEASON_PILLS`. */
|
||||
private const val SEASON_CHIPS = 5
|
||||
|
||||
/** The season shown, as a button that opens the list of all of them. */
|
||||
@Composable
|
||||
private fun SeasonList(seasons: List<SeasonRecord>, picked: Int, onPick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box(modifier) {
|
||||
FilterChip(
|
||||
selected = true,
|
||||
onClick = { open = true },
|
||||
label = { Text(seasons.getOrNull(picked)?.label.orEmpty()) },
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
|
||||
)
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(season.label) },
|
||||
onClick = {
|
||||
open = false
|
||||
onPick(index)
|
||||
},
|
||||
trailingIcon = if (index == picked) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = "Shown") }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (title.airing) "airing" else null
|
||||
}
|
||||
|
||||
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||
@@ -521,16 +827,35 @@ private fun EpisodeRow(
|
||||
onPause: (RetainedDownload) -> Unit,
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onSetWatched: (Boolean) -> Unit,
|
||||
onWatchedUpTo: () -> Unit = {},
|
||||
) {
|
||||
// A long press, for someone joining a show partway: everything before
|
||||
// this one ticked at once rather than one by one.
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||
download?.status == RetainedDownload.STATUS_QUEUED
|
||||
val heading = episode.heading(position)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = playerReady, onClick = onPlay)
|
||||
.combinedClickable(
|
||||
enabled = playerReady,
|
||||
onClick = onPlay,
|
||||
onLongClick = { menu = true },
|
||||
onLongClickLabel = "More for this episode",
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Mark watched up to here") },
|
||||
leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) },
|
||||
onClick = {
|
||||
menu = false
|
||||
onWatchedUpTo()
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// With metadata off the provider has no still, and Proton's own
|
||||
// thumbnail is a frame of this very episode. Where it was left sits
|
||||
@@ -555,6 +880,21 @@ private fun EpisodeRow(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
// Seen, said on the picture as well as by the mark at the
|
||||
// row's end: a season is scanned by its stills.
|
||||
if (episode.watched) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(5.dp)
|
||||
.size(18.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(
|
||||
@@ -573,7 +913,9 @@ private fun EpisodeRow(
|
||||
Text(
|
||||
listOfNotNull(
|
||||
numbering,
|
||||
episode.size?.let(::formatBytes),
|
||||
// How long it runs says more than how big it is; the
|
||||
// size is what there is when no provider said.
|
||||
episode.runtime?.let(::formatRuntime) ?: episode.size?.let(::formatBytes),
|
||||
episode.airDate,
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -636,7 +978,7 @@ private fun EpisodeRow(
|
||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||
)
|
||||
Text(
|
||||
downloadStatusLine(active),
|
||||
downloadStatusLine(active, rememberQueueReason(active)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -656,13 +998,39 @@ internal fun ChangeMatchDialog(
|
||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||
var confirmForget by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
if (confirmForget) {
|
||||
// Asked, because a match chosen by hand is work the next look-up
|
||||
// does not redo: it decides again on its own.
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmForget = false },
|
||||
title = { Text("Forget this match?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its poster, description and episode names go, and the next look-up " +
|
||||
"matches it again on its own. A match you chose by hand is lost.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(onClick = {
|
||||
confirmForget = false
|
||||
scope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||
.onSuccess { onChanged() }.onFailure(onError)
|
||||
}
|
||||
}) { Text("Forget") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } },
|
||||
)
|
||||
return
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Change match") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
||||
Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
|
||||
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
||||
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
|
||||
searching = true
|
||||
@@ -697,7 +1065,10 @@ internal fun ChangeMatchDialog(
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(option.name, fontWeight = FontWeight.SemiBold)
|
||||
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
|
||||
Text(
|
||||
listOfNotNull(option.year?.toString(), option.originalName, option.provider.displayName())
|
||||
.joinToString(" · "),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,12 +1076,7 @@ internal fun ChangeMatchDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
QuietButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||
.onSuccess { onChanged() }.onFailure(onError)
|
||||
}
|
||||
}) { Text("Forget match") }
|
||||
QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
|
||||
@@ -142,6 +142,28 @@ internal fun QuietButton(
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* The yes of a confirmation that deletes or forgets something.
|
||||
*
|
||||
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
|
||||
* whose most inviting button is the one that loses something reads as the
|
||||
* safe choice. Error-toned text instead, as Material's own guidance has it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DangerButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far along something is, drawn in the accent.
|
||||
*
|
||||
|
||||
@@ -56,6 +56,12 @@ object AppearanceState {
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Read the stored choice again, after sync rewrote it. */
|
||||
suspend fun reload() {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Repaint immediately, before the write that stores the choice lands. */
|
||||
fun apply(palette: PaletteRecord) {
|
||||
current.value = palette
|
||||
|
||||
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
|
||||
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
/**
|
||||
* Inter Display Bold, the optical size drawn for large text, for the one
|
||||
* [HERO] line: the banner's name and the name at the top of a title's page.
|
||||
* The desktop's `theme::display` family, from the same file.
|
||||
*/
|
||||
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
|
||||
|
||||
/**
|
||||
* The type ramp, which is the desktop client's type ramp.
|
||||
*
|
||||
@@ -33,7 +40,10 @@ internal val Inter = FontFamily(
|
||||
* were built to share one palette; sharing one scale is the other half of why
|
||||
* they should read as one app.
|
||||
*
|
||||
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
* The top one is the hero line, in [InterDisplay] and set tighter, at
|
||||
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
|
||||
* apart.
|
||||
*
|
||||
* Where a role maps is decided by what actually draws with it here, not by
|
||||
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
||||
@@ -44,7 +54,8 @@ internal val Inter = FontFamily(
|
||||
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||
*/
|
||||
internal val ProtonTypography = Typography(
|
||||
displayLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||
// The banner's name and the title page's name, where no logo stands in.
|
||||
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
|
||||
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||
// The name of the title you opened — the desktop's display line.
|
||||
@@ -69,6 +80,7 @@ internal val ProtonTypography = Typography(
|
||||
labelSmall = role(MICRO, FontWeight.Medium),
|
||||
)
|
||||
|
||||
internal const val HERO = 34f
|
||||
internal const val DISPLAY = 26f
|
||||
internal const val TITLE = 20f
|
||||
internal const val HEADING = 18f
|
||||
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
|
||||
internal const val CAPTION = 12f
|
||||
internal const val MICRO = 11f
|
||||
|
||||
internal const val LEADING = 1.35f
|
||||
internal const val HERO_LEADING = 1.1f
|
||||
|
||||
/**
|
||||
* One rung as a `TextStyle`.
|
||||
*
|
||||
@@ -87,9 +102,14 @@ internal const val MICRO = 11f
|
||||
* enough for the two-line title names in the grid and tight enough that a
|
||||
* label on a control does not float.
|
||||
*/
|
||||
private fun role(size: Float, weight: FontWeight) = TextStyle(
|
||||
private fun role(
|
||||
size: Float,
|
||||
weight: FontWeight,
|
||||
family: FontFamily = Inter,
|
||||
leading: Float = LEADING,
|
||||
) = TextStyle(
|
||||
fontSize = size.sp,
|
||||
lineHeight = (size * 1.35f).sp,
|
||||
lineHeight = (size * leading).sp,
|
||||
fontWeight = weight,
|
||||
fontFamily = Inter,
|
||||
fontFamily = family,
|
||||
)
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -9,7 +9,7 @@ class AccountUiTest {
|
||||
@Test
|
||||
fun `the sync line says how long ago and what came in`() {
|
||||
val minute = 60_000L
|
||||
assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||
assertEquals(
|
||||
@@ -21,7 +21,7 @@ class AccountUiTest {
|
||||
@Test
|
||||
fun `a failed sync is said in place of when it last worked`() {
|
||||
assertEquals(
|
||||
"Watch history did not sync: offline",
|
||||
"Did not sync: offline",
|
||||
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
class EpisodeNameTest {
|
||||
private fun episode(name: String, detail: String, providerName: String? = null) = EpisodeRecord(
|
||||
shareId = "s", volumeId = "v", linkId = "l", name = name, label = "S01E01", detail = detail,
|
||||
season = 1u, number = 1u, size = null, progress = null, resumeAt = null,
|
||||
watched = false, offline = false, providerName = providerName, providerOverview = null, stillUrl = null,
|
||||
airDate = null, lastPlayed = 0,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the player captions an episode with its name and never its filename`() {
|
||||
val file = "[Group] Show - S01E01.mkv"
|
||||
assertEquals("Dog & Chainsaw", episode(file, file, providerName = "Dog & Chainsaw").shownName())
|
||||
assertEquals("Winter Is Coming", episode(file, "Winter Is Coming").shownName())
|
||||
assertNull(episode(file, file).shownName())
|
||||
assertNull(episode(file, "").shownName())
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class HistoryTest {
|
||||
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
continueIndex = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LogoAndRuntimeTest {
|
||||
@Test
|
||||
fun `a padded logo is bounded by its lettering and not its haze`() {
|
||||
val width = 10
|
||||
val pixels = IntArray(width * 6)
|
||||
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
|
||||
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
|
||||
pixels[5 * width + 9] = 0x03FFFFFF
|
||||
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a logo with nothing visible has no bounds`() {
|
||||
assertNull(opaqueBounds(IntArray(16), 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running time reads in hours and minutes`() {
|
||||
assertEquals("24 min", formatRuntime(24u))
|
||||
assertEquals("2 h", formatRuntime(120u))
|
||||
assertEquals("2 h 4 min", formatRuntime(124u))
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
@@ -34,6 +36,7 @@ import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.ShelfRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.TileRecord
|
||||
import uniffi.pstr_android.CastRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
@@ -79,18 +82,23 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the library on a phone`() = capture("library-phone") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the library on a tablet`() = capture("library-tablet") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,7 +124,7 @@ class ScreenshotTest {
|
||||
// reads it from, so the scale is set there.
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,22 +145,22 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the shares page`() = capture("shares-phone") {
|
||||
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the settings page`() = capture("settings-phone") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the playback settings`() = capture("settings-playback") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the storage settings`() = capture("settings-storage") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -264,6 +272,9 @@ class ScreenshotTest {
|
||||
nextEpisode = null,
|
||||
nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
// What the engine would pick for these fixtures: the part-watched episode.
|
||||
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
|
||||
.takeIf { it >= 0 }?.toUInt(),
|
||||
)
|
||||
|
||||
val SERIES = title(
|
||||
@@ -281,6 +292,11 @@ class ScreenshotTest {
|
||||
directors = listOf("Daisuke Hiramaki"),
|
||||
tags = listOf("Idol", "Reincarnation"),
|
||||
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
|
||||
trailerUrl = "https://www.youtube.com/watch?v=trailer",
|
||||
cast = listOf(
|
||||
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
|
||||
CastRecord("Aqua", null, "Takeo Ootsuka", null),
|
||||
),
|
||||
)
|
||||
|
||||
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
|
||||
@@ -290,6 +306,16 @@ class ScreenshotTest {
|
||||
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||
.copy(kind = TitleType.FILM, year = 1988u)
|
||||
|
||||
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
|
||||
val MATCHED get() = LIBRARY.copy(
|
||||
metadataSettings = MetadataSettingsRecord(
|
||||
enabled = true,
|
||||
provider = MetadataProvider.ANI_LIST,
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
)
|
||||
|
||||
val LIBRARY = AppUiState(
|
||||
loading = false,
|
||||
titles = listOf(
|
||||
@@ -306,9 +332,17 @@ class ScreenshotTest {
|
||||
},
|
||||
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
||||
),
|
||||
// Crawled relative to the clock, so the row reads "2 h ago" on
|
||||
// whatever day the test runs.
|
||||
shares = listOf(
|
||||
ShareRecord("anime", "anime", true),
|
||||
ShareRecord("films", "films", false),
|
||||
ShareRecord(
|
||||
"anime",
|
||||
"anime",
|
||||
true,
|
||||
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
|
||||
crawlFiles = 120uL,
|
||||
),
|
||||
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
|
||||
),
|
||||
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
class StatusTest {
|
||||
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
|
||||
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||
episodeCount = 0uL, canonicalName = null, originalName = null, overview = null,
|
||||
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||
manualMatch = false, seasons = emptyList(),
|
||||
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
|
||||
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the status chip names the next episode while one is due`() {
|
||||
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
|
||||
assertTrue(text, text.startsWith("Airing · episode 8 "))
|
||||
assertTrue(live)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished title is said plainly and is not live`() {
|
||||
assertEquals("Finished" to false, statusText(title("Finished")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no status and nothing airing has no chip`() {
|
||||
assertNull(statusText(title(null)))
|
||||
assertEquals("Airing" to true, statusText(title(null, airing = true)))
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ import org.junit.Test
|
||||
*/
|
||||
class TypeRampTest {
|
||||
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
|
||||
private val expected = mapOf(
|
||||
"displayLarge" to 26f,
|
||||
"displayLarge" to 34f,
|
||||
"displayMedium" to 26f,
|
||||
"displaySmall" to 20f,
|
||||
"headlineLarge" to 26f,
|
||||
@@ -62,12 +62,14 @@ class TypeRampTest {
|
||||
/**
|
||||
* Leading is derived from the size, so the only thing worth asserting is
|
||||
* that it *was* derived — a style that kept Material's line height carries
|
||||
* a number with no relation to its own size.
|
||||
* a number with no relation to its own size. The hero line is the one set
|
||||
* tighter.
|
||||
*/
|
||||
@Test
|
||||
fun `line height follows the size it belongs to`() {
|
||||
styles().forEach { (name, style) ->
|
||||
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
|
||||
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
|
||||
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
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 |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 867 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 828 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.1 MiB |
|
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: 606 KiB |
|
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 619 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
|
||||
egui.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
|
||||
sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
@@ -39,8 +41,6 @@ tao.workspace = true
|
||||
[build-dependencies]
|
||||
embed-resource.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
skrifa.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
dbus.workspace = true
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize};
|
||||
/// What the settings page offers for the streaming cache, in GiB.
|
||||
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
||||
|
||||
/// What the settings page offers for the size of everything, in percent.
|
||||
pub const TEXT_SIZE_CHOICES: [u16; 5] = [90, 100, 115, 130, 150];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
// A file from an older version, or edited by hand, missing a field: the rest
|
||||
// still counts, and the missing one takes its default.
|
||||
@@ -21,6 +24,15 @@ pub struct DesktopPrefs {
|
||||
pub hardware_decoding: bool,
|
||||
/// How much decrypted video the disk cache may keep.
|
||||
pub cache_gib: u32,
|
||||
/// How the library was last arranged, so it opens that way.
|
||||
pub filter: crate::app::Filter,
|
||||
pub sort: pstr_core::browse::Sort,
|
||||
pub grouped: bool,
|
||||
/// The library's offer of posters and synopses was turned down.
|
||||
pub metadata_offer_dismissed: bool,
|
||||
/// How large text and controls are drawn, in percent. Per computer, not
|
||||
/// synced: what reads well depends on the screen and how far away it is.
|
||||
pub text_size: u16,
|
||||
}
|
||||
|
||||
impl Default for DesktopPrefs {
|
||||
@@ -28,11 +40,23 @@ impl Default for DesktopPrefs {
|
||||
Self {
|
||||
hardware_decoding: true,
|
||||
cache_gib: 4,
|
||||
filter: crate::app::Filter::default(),
|
||||
sort: pstr_core::browse::Sort::default(),
|
||||
// On: a share holding a series and its three films is four tiles
|
||||
// of the same picture otherwise.
|
||||
grouped: true,
|
||||
metadata_offer_dismissed: false,
|
||||
text_size: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopPrefs {
|
||||
/// [`Self::text_size`] as egui's zoom factor, kept to what is offered.
|
||||
pub fn zoom(&self) -> f32 {
|
||||
f32::from(self.text_size.clamp(50, 200)) / 100.0
|
||||
}
|
||||
|
||||
pub fn cache_bytes(&self) -> u64 {
|
||||
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||
}
|
||||
@@ -93,6 +117,17 @@ mod tests {
|
||||
assert!(prefs.hardware_decoding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_size_from_an_older_file_is_full_size_and_a_wild_one_is_kept_in_range() {
|
||||
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
|
||||
assert_eq!(prefs.zoom(), 1.0);
|
||||
let wild = DesktopPrefs {
|
||||
text_size: 900,
|
||||
..DesktopPrefs::default()
|
||||
};
|
||||
assert_eq!(wild.zoom(), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_file_is_never_overwritten() {
|
||||
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||
|
||||
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
|
||||
use pstr_core::library::{Episode, Title};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_player::{
|
||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
|
||||
VideoOutput,
|
||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
|
||||
TrackKind, VideoOutput,
|
||||
};
|
||||
use pstr_stream::VideoStream;
|
||||
|
||||
@@ -147,6 +147,10 @@ pub enum Command {
|
||||
/// Move subtitles, as a percentage of the window's height. See
|
||||
/// [`Playback::lift_subtitles`].
|
||||
SetSubtitlePosition(f64),
|
||||
/// Restyle subtitles, the line already showing included.
|
||||
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
|
||||
/// Shift subtitles this many seconds later; negative is earlier.
|
||||
SetSubtitleDelay(f64),
|
||||
/// 1.0 is normal speed.
|
||||
SetSpeed(f64),
|
||||
/// Save the current frame to this file.
|
||||
@@ -184,10 +188,26 @@ pub struct Playback {
|
||||
/// Where subtitles were last put, so they are only moved when that changes
|
||||
/// rather than once a frame.
|
||||
subtitle_position: f64,
|
||||
/// How the viewer has subtitles drawn, as last handed to this player.
|
||||
pub subtitle_style: pstr_core::prefs::SubtitleStyle,
|
||||
/// Where the controls would lift subtitles to. The higher of this and the
|
||||
/// style's own position wins.
|
||||
lifted_position: f64,
|
||||
/// How far subtitles are shifted, in seconds. Per file rather than a
|
||||
/// preference: an offset belongs to one release, not to the viewer.
|
||||
pub subtitle_delay: f64,
|
||||
/// What the file contains. Empty until mpv has demuxed it.
|
||||
pub tracks: Vec<Track>,
|
||||
/// The file's chapters, in order. Empty for a file muxed without them.
|
||||
/// The chapters to show and act on, in order: the file's own, with
|
||||
/// AniSkip's opening and ending cut in when the file names neither. Empty
|
||||
/// for a file muxed without chapters that AniSkip does not know.
|
||||
pub chapters: Vec<Chapter>,
|
||||
/// The chapters as the file has them.
|
||||
file_chapters: Vec<Chapter>,
|
||||
/// What AniSkip said, once it has.
|
||||
skip_ranges: Vec<pstr_core::chapters::SkipRange>,
|
||||
/// Whether AniSkip has been asked about this file, so it is asked once.
|
||||
pub skips_asked: bool,
|
||||
/// What each of those chapters is, resolved against the rest of the file —
|
||||
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
|
||||
/// whenever either the chapters or the duration change.
|
||||
@@ -259,10 +279,16 @@ impl Playback {
|
||||
paused: false,
|
||||
volume: prefs.volume,
|
||||
muted: prefs.muted,
|
||||
subtitle_position: 100.0,
|
||||
subtitle_position: prefs.subtitle_style.position,
|
||||
subtitle_style: prefs.subtitle_style.clone(),
|
||||
lifted_position: 100.0,
|
||||
subtitle_delay: 0.0,
|
||||
speed: prefs.speed,
|
||||
tracks: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
file_chapters: Vec::new(),
|
||||
skip_ranges: Vec::new(),
|
||||
skips_asked: false,
|
||||
roles: Vec::new(),
|
||||
credits_at: None,
|
||||
position: target.resume_at.unwrap_or(0.0),
|
||||
@@ -282,17 +308,57 @@ impl Playback {
|
||||
/// the mouse is moving, which is exactly when someone is looking at the
|
||||
/// screen.
|
||||
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
|
||||
let position = if covered > 0.0 && height > 0.0 {
|
||||
self.lifted_position = if covered > 0.0 && height > 0.0 {
|
||||
(100.0 * (1.0 - f64::from(covered / height))).round()
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
self.place_subtitles();
|
||||
}
|
||||
|
||||
/// Move subtitles to [`Self::subtitle_position`], when that has changed.
|
||||
fn place_subtitles(&mut self) {
|
||||
let position = self.subtitle_position();
|
||||
if position != self.subtitle_position {
|
||||
self.subtitle_position = position;
|
||||
self.send(Command::SetSubtitlePosition(position));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where subtitles go: where the style puts them, unless the controls
|
||||
/// would cover that, in which case just above the controls.
|
||||
///
|
||||
/// A styled track ignores the style's position unless it is restyled:
|
||||
/// mpv applies `sub-pos` to it either way, and its signs were placed by
|
||||
/// hand. The lift still applies, because a line under the seek bar cannot
|
||||
/// be read however carefully it was placed.
|
||||
fn subtitle_position(&self) -> f64 {
|
||||
let styled = self
|
||||
.selected_track(TrackKind::Subtitle)
|
||||
.is_some_and(|track| is_styled(track.codec.as_deref()));
|
||||
let position = if styled && !self.subtitle_style.restyle_styled {
|
||||
100.0
|
||||
} else {
|
||||
self.subtitle_style.position
|
||||
};
|
||||
position.min(self.lifted_position)
|
||||
}
|
||||
|
||||
/// Restyle the subtitles of this file, now.
|
||||
pub fn restyle_subtitles(&mut self, style: &pstr_core::prefs::SubtitleStyle) {
|
||||
self.subtitle_style = style.clone();
|
||||
self.subtitle_position = self.subtitle_position();
|
||||
let mut style = style.clone();
|
||||
style.position = self.subtitle_position;
|
||||
self.send(Command::SetSubtitleStyle(style));
|
||||
}
|
||||
|
||||
/// Shift subtitles to `seconds`, rounded to the tenth a keypress moves.
|
||||
pub fn set_subtitle_delay(&mut self, seconds: f64) {
|
||||
self.subtitle_delay = (seconds * 10.0).round() / 10.0;
|
||||
self.send(Command::SetSubtitleDelay(self.subtitle_delay));
|
||||
}
|
||||
|
||||
/// Whether the picture is drawn in this window rather than mpv's own.
|
||||
pub fn is_embedded(&self) -> bool {
|
||||
self.video.is_some()
|
||||
@@ -338,6 +404,18 @@ impl Playback {
|
||||
(end > self.position + 0.5).then_some((label, end))
|
||||
}
|
||||
|
||||
/// Whether the chapters say where the credits start. Most files that are
|
||||
/// not anime have no chapters at all.
|
||||
pub fn knows_credits(&self) -> bool {
|
||||
self.credits_at.is_some()
|
||||
}
|
||||
|
||||
/// Seconds of the file left to play, once its length is known.
|
||||
pub fn remaining(&self) -> Option<f64> {
|
||||
self.duration
|
||||
.map(|duration| (duration - self.position).max(0.0))
|
||||
}
|
||||
|
||||
/// Whether the playhead is inside the run of credits and preview that ends
|
||||
/// the file — what "the episode is over" means when a file states its
|
||||
/// chapters.
|
||||
@@ -350,6 +428,11 @@ impl Playback {
|
||||
/// changed. Both are needed: the length of a chapter is what settles a name
|
||||
/// that could mean two things.
|
||||
fn reclassify(&mut self) {
|
||||
self.chapters = pstr_core::chapters::with_skip_ranges(
|
||||
&self.file_chapters,
|
||||
&self.skip_ranges,
|
||||
self.duration,
|
||||
);
|
||||
self.roles = pstr_player::roles(&self.chapters, self.duration);
|
||||
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
|
||||
}
|
||||
@@ -360,9 +443,13 @@ impl Playback {
|
||||
PlayerEvent::FileLoaded => self.loaded = true,
|
||||
PlayerEvent::Volume(volume) => self.volume = *volume,
|
||||
PlayerEvent::Muted(muted) => self.muted = *muted,
|
||||
PlayerEvent::Tracks(tracks) => self.tracks = tracks.clone(),
|
||||
PlayerEvent::Tracks(tracks) => {
|
||||
self.tracks = tracks.clone();
|
||||
// A switch between a plain and a styled track moves the line.
|
||||
self.place_subtitles();
|
||||
}
|
||||
PlayerEvent::Chapters(chapters) => {
|
||||
self.chapters = chapters.clone();
|
||||
self.file_chapters = chapters.clone();
|
||||
self.reclassify();
|
||||
}
|
||||
PlayerEvent::Position(position) => {
|
||||
@@ -395,6 +482,26 @@ impl Playback {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's length, when it is time to ask AniSkip where its opening
|
||||
/// and ending are: once, after it has loaded, and only when its own
|
||||
/// chapters name neither.
|
||||
pub fn wants_skip_times(&self) -> Option<f64> {
|
||||
if self.skips_asked || !self.loaded {
|
||||
return None;
|
||||
}
|
||||
let own = pstr_player::roles(&self.file_chapters, self.duration);
|
||||
let named = own
|
||||
.iter()
|
||||
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending));
|
||||
self.duration.filter(|duration| *duration > 0.0 && !named)
|
||||
}
|
||||
|
||||
/// Take AniSkip's answer.
|
||||
pub fn set_skip_ranges(&mut self, ranges: Vec<pstr_core::chapters::SkipRange>) {
|
||||
self.skip_ranges = ranges;
|
||||
self.reclassify();
|
||||
}
|
||||
|
||||
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
|
||||
pub fn progress(&self) -> Option<f32> {
|
||||
let duration = self.duration.filter(|duration| *duration > 0.0)?;
|
||||
@@ -467,6 +574,11 @@ fn build_player(
|
||||
Ok((Arc::new(player), None))
|
||||
}
|
||||
|
||||
/// Whether a subtitle codec carries its own styling, as mpv names them.
|
||||
fn is_styled(codec: Option<&str>) -> bool {
|
||||
matches!(codec, Some("ass" | "ssa"))
|
||||
}
|
||||
|
||||
/// The player defaults with the viewer's preferences folded in.
|
||||
fn from_prefs(
|
||||
prefs: &PlaybackPrefs,
|
||||
@@ -482,6 +594,7 @@ fn from_prefs(
|
||||
.and_then(|p| p.subtitle_language.clone())
|
||||
.or_else(|| prefs.subtitle_language.clone()),
|
||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||
subtitle_style: prefs.subtitle_style.clone(),
|
||||
// Carried from file to file, like the volume: somebody who watches at
|
||||
// 1.25 watches the next episode at 1.25 too.
|
||||
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
||||
@@ -504,7 +617,11 @@ fn run(
|
||||
orders: std::sync::mpsc::Receiver<Command>,
|
||||
finished: Arc<AtomicBool>,
|
||||
) {
|
||||
let handle = match player.play(stream) {
|
||||
let opened = match target.resume_at {
|
||||
Some(at) => player.play_from(stream, at),
|
||||
None => player.play(stream),
|
||||
};
|
||||
let handle = match opened {
|
||||
Ok(handle) => handle,
|
||||
Err(error) => {
|
||||
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
||||
@@ -514,10 +631,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 duration: Option<f64> = None;
|
||||
let mut watched = false;
|
||||
let mut resumed = target.resume_at.is_none();
|
||||
let mut last_save = std::time::Instant::now();
|
||||
|
||||
'playback: loop {
|
||||
@@ -543,6 +668,10 @@ fn run(
|
||||
match &event {
|
||||
PlayerEvent::FileLoaded => {
|
||||
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
|
||||
// demuxed the file and made its own default selection. Put the
|
||||
// title's own choice over it before the menus are told, so they
|
||||
@@ -550,14 +679,16 @@ fn run(
|
||||
if let Some(show) = target.track_prefs.as_deref() {
|
||||
restore_tracks(&player, show);
|
||||
}
|
||||
emit_tracks(&engine, id, &player);
|
||||
// Only now is there a timeline to seek within.
|
||||
if !resumed && let Some(at) = target.resume_at {
|
||||
resumed = true;
|
||||
if let Err(error) = player.seek_to(at) {
|
||||
tracing::warn!("resume at {at}: {error}");
|
||||
}
|
||||
// A subtitle track picked by name stays; one mpv picked by
|
||||
// language may be the signs and songs, not the dialogue.
|
||||
let picked = target
|
||||
.track_prefs
|
||||
.as_deref()
|
||||
.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::Position(value) => {
|
||||
@@ -599,6 +730,7 @@ fn run(
|
||||
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
||||
// OpenGL context its render context was built against.
|
||||
drop(handle);
|
||||
drop(subtitles);
|
||||
drop(player);
|
||||
|
||||
engine.release(
|
||||
@@ -627,6 +759,11 @@ fn apply_command(
|
||||
Command::SetVolume(volume) => player.set_volume(volume),
|
||||
Command::SetMuted(muted) => player.set_muted(muted),
|
||||
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
||||
Command::SetSubtitleStyle(style) => {
|
||||
player.apply_subtitle_style(&style);
|
||||
Ok(())
|
||||
}
|
||||
Command::SetSubtitleDelay(seconds) => player.set_subtitle_delay(seconds),
|
||||
Command::SetSpeed(speed) => player.set_speed(speed),
|
||||
Command::Screenshot(path) => player.screenshot(&path),
|
||||
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
||||
@@ -713,6 +850,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.
|
||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||
engine.emit(Event::Player {
|
||||
@@ -738,3 +926,17 @@ fn save(
|
||||
watch_state(position, duration, watched),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_ass_and_ssa_tracks_count_as_styled() {
|
||||
assert!(is_styled(Some("ass")));
|
||||
assert!(is_styled(Some("ssa")));
|
||||
assert!(!is_styled(Some("subrip")));
|
||||
assert!(!is_styled(Some("hdmv_pgs_subtitle")));
|
||||
assert!(!is_styled(None));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
|
||||
pub const POSTER_WIDTH: f32 = 168.0;
|
||||
/// Covers are 2:3.
|
||||
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
||||
/// How much a tile's picture grows under the pointer, as a fraction of its
|
||||
/// size. Half of it lands on each side, which has to fit in half of
|
||||
/// [`CARD_GAP`] at the widest card, or a lifted tile overlaps its neighbour.
|
||||
pub const TILE_LIFT: f32 = 0.04;
|
||||
|
||||
/// The type ramp, named once.
|
||||
///
|
||||
@@ -55,6 +59,11 @@ pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
||||
/// the one monospace style in the app is egui's own, set in [`apply`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// The name in the library's banner and at the top of a title's page,
|
||||
/// where there is no logo to stand in for it. Set in Inter Display, the
|
||||
/// cut drawn for this size: tighter and heavier than the text face, which
|
||||
/// at forty points looks like body copy that was zoomed.
|
||||
Hero,
|
||||
/// The name of the thing you are looking at, once per page.
|
||||
Display,
|
||||
/// An empty state, or the heading of a page that has no display line.
|
||||
@@ -78,6 +87,7 @@ pub enum Role {
|
||||
impl Role {
|
||||
pub const fn size(self) -> f32 {
|
||||
match self {
|
||||
Self::Hero => 34.0,
|
||||
Self::Display => 26.0,
|
||||
Self::Title => 20.0,
|
||||
Self::Heading => 18.0,
|
||||
@@ -90,10 +100,11 @@ impl Role {
|
||||
}
|
||||
}
|
||||
|
||||
/// Headings are set in the semibold cut; everything below them in the
|
||||
/// regular one.
|
||||
/// The hero line is set in the display cut, headings in the semibold
|
||||
/// one, and everything below them in the regular one.
|
||||
pub fn family(self) -> egui::FontFamily {
|
||||
match self {
|
||||
Self::Hero => display(),
|
||||
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
||||
_ => egui::FontFamily::Proportional,
|
||||
}
|
||||
@@ -159,6 +170,9 @@ pub mod motion {
|
||||
/// Artwork arriving over its placeholder. Slower, because it replaces
|
||||
/// something rather than adding to it, and a fast swap reads as a flicker.
|
||||
pub const ARRIVE: f32 = 0.24;
|
||||
/// One pass of the highlight across a loading skeleton. Slow, because it
|
||||
/// is something to wait in front of, not something happening.
|
||||
pub const SHIMMER: f32 = 1.4;
|
||||
|
||||
/// Fast out, slow in: the curve every hand-driven transition uses.
|
||||
pub fn ease_out(t: f32) -> f32 {
|
||||
@@ -170,7 +184,7 @@ pub mod motion {
|
||||
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
||||
/// beside the files.
|
||||
///
|
||||
/// Both files have their private use area mappings removed, because the
|
||||
/// All three files have their private use area mappings removed, because the
|
||||
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
||||
/// own alternate glyphs, and as the first font in the family it won: the
|
||||
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
||||
@@ -179,6 +193,9 @@ pub mod motion {
|
||||
/// U+E000..=U+F8FF).
|
||||
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
||||
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
||||
/// Inter Display Bold, the optical size for the hero line. Stripped like the
|
||||
/// other two.
|
||||
const INTER_DISPLAY: &[u8] = include_bytes!("../assets/fonts/InterDisplay-Bold.ttf");
|
||||
|
||||
/// The family the headings are set in. egui has no notion of weight, so the
|
||||
/// semibold cut is a family of its own.
|
||||
@@ -186,9 +203,53 @@ pub fn semibold() -> egui::FontFamily {
|
||||
egui::FontFamily::Name("semibold".into())
|
||||
}
|
||||
|
||||
/// The family [`Role::Hero`] is set in.
|
||||
pub fn display() -> egui::FontFamily {
|
||||
egui::FontFamily::Name("display".into())
|
||||
}
|
||||
|
||||
/// The family the subtitle preview is drawn in: the face the chosen subtitle
|
||||
/// font resolves to, ahead of the app's own fonts, which stand in until it has
|
||||
/// loaded and for any glyph it lacks.
|
||||
pub fn subtitle_family() -> egui::FontFamily {
|
||||
egui::FontFamily::Name("subtitle".into())
|
||||
}
|
||||
|
||||
/// The system fonts loaded so far. Each install builds the definitions from
|
||||
/// all of them, so loading one does not drop the other.
|
||||
#[derive(Default)]
|
||||
struct SystemFonts {
|
||||
cjk: Option<Arc<egui::FontData>>,
|
||||
subtitle: Option<(SubtitleRequest, Arc<egui::FontData>, SubtitleFace)>,
|
||||
/// The subtitle face last asked for, loading or loaded.
|
||||
wanted: Option<SubtitleRequest>,
|
||||
}
|
||||
|
||||
static SYSTEM_FONTS: parking_lot::Mutex<SystemFonts> = parking_lot::Mutex::new(SystemFonts {
|
||||
cjk: None,
|
||||
subtitle: None,
|
||||
wanted: None,
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SubtitleRequest {
|
||||
family: String,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
/// What the loaded subtitle face draws by itself. A style it lacks is one
|
||||
/// libass makes up, and so must the preview.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SubtitleFace {
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
|
||||
/// symbols Inter does not carry. `cjk` goes last, when there is one.
|
||||
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||
/// symbols Inter does not carry. The CJK fallback goes last, when there is
|
||||
/// one.
|
||||
fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
|
||||
use egui::FontFamily::{Monospace, Proportional};
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
@@ -199,6 +260,10 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||
"inter-semibold".into(),
|
||||
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"inter-display".into(),
|
||||
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.entry(Proportional)
|
||||
@@ -207,21 +272,32 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||
// Right behind Inter. The icons are in the private use area, which the
|
||||
// bundled Inter no longer maps, so neither font shadows the other.
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
if let Some(cjk) = cjk {
|
||||
if let Some(cjk) = &system.cjk {
|
||||
let name = "system-cjk-fallback".to_owned();
|
||||
fonts.font_data.insert(name.clone(), Arc::new(cjk));
|
||||
fonts.font_data.insert(name.clone(), cjk.clone());
|
||||
for family in [Proportional, Monospace] {
|
||||
fonts.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
let fallbacks = fonts.families[&Proportional]
|
||||
let fallbacks: Vec<String> = fonts.families[&Proportional]
|
||||
.iter()
|
||||
.filter(|name| *name != "inter")
|
||||
.cloned();
|
||||
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
||||
.chain(fallbacks)
|
||||
.cloned()
|
||||
.collect();
|
||||
fonts.families.insert(semibold(), semibold_chain);
|
||||
let chain = |first: &str| {
|
||||
std::iter::once(first.to_owned())
|
||||
.chain(fallbacks.iter().cloned())
|
||||
.collect()
|
||||
};
|
||||
fonts.families.insert(semibold(), chain("inter-semibold"));
|
||||
fonts.families.insert(display(), chain("inter-display"));
|
||||
let mut subtitle_chain = fonts.families[&Proportional].clone();
|
||||
if let Some((_, face, _)) = &system.subtitle {
|
||||
let name = "system-subtitle".to_owned();
|
||||
fonts.font_data.insert(name.clone(), face.clone());
|
||||
subtitle_chain.insert(0, name);
|
||||
}
|
||||
fonts.families.insert(subtitle_family(), subtitle_chain);
|
||||
fonts
|
||||
}
|
||||
|
||||
@@ -234,7 +310,7 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
||||
/// and native desktops already provide one.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
ctx.set_fonts(font_definitions(None));
|
||||
ctx.set_fonts(font_definitions(&SYSTEM_FONTS.lock()));
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("fonts".into())
|
||||
@@ -248,13 +324,98 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
||||
let Some((path, index)) = system_cjk_font() else {
|
||||
return;
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return;
|
||||
};
|
||||
let Some(data) = system_font_data(bytes, index) else {
|
||||
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
||||
return;
|
||||
};
|
||||
let mut system = SYSTEM_FONTS.lock();
|
||||
system.cjk = Some(Arc::new(data));
|
||||
ctx.set_fonts(font_definitions(&system));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The face the subtitle preview should be drawn in for this font, bold and
|
||||
/// italic, once [`subtitle_family`] holds it.
|
||||
///
|
||||
/// `None` while it loads, and for good where the system cannot say which file
|
||||
/// a family is — everywhere but Linux. The first call for a font starts the
|
||||
/// load on a thread of its own, since `fc-match` and reading the file are too
|
||||
/// slow for a frame, and asks for a repaint when it is in.
|
||||
pub fn subtitle_face(
|
||||
ctx: &egui::Context,
|
||||
family: &str,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
) -> Option<SubtitleFace> {
|
||||
let request = SubtitleRequest {
|
||||
family: family.to_owned(),
|
||||
bold,
|
||||
italic,
|
||||
};
|
||||
let mut system = SYSTEM_FONTS.lock();
|
||||
if let Some((loaded, _, face)) = &system.subtitle
|
||||
&& *loaded == request
|
||||
{
|
||||
return Some(*face);
|
||||
}
|
||||
if system.wanted.as_ref() != Some(&request) {
|
||||
system.wanted = Some(request.clone());
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("subtitle-font".into())
|
||||
.spawn(move || install_subtitle_face(&ctx, request));
|
||||
if let Err(error) = spawned {
|
||||
tracing::debug!("subtitle font thread: {error}");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn install_subtitle_face(ctx: &egui::Context, request: SubtitleRequest) {
|
||||
let Some((path, index, face)) = system_subtitle_font(&request) else {
|
||||
return;
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return;
|
||||
};
|
||||
let Some(data) = system_font_data(bytes, index) else {
|
||||
tracing::debug!("subtitle font {}#{index} does not parse", path.display());
|
||||
return;
|
||||
};
|
||||
let mut system = SYSTEM_FONTS.lock();
|
||||
// Another font was picked while this one loaded.
|
||||
if system.wanted.as_ref() != Some(&request) {
|
||||
return;
|
||||
}
|
||||
system.subtitle = Some((request, Arc::new(data), face));
|
||||
ctx.set_fonts(font_definitions(&system));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// A system 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 system_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;
|
||||
ctx.set_fonts(font_definitions(Some(data)));
|
||||
ctx.request_repaint();
|
||||
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")]
|
||||
@@ -269,10 +430,73 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
let index = lines
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.map(fontconfig_face)
|
||||
.unwrap_or(0);
|
||||
path.is_file().then_some((path, index))
|
||||
}
|
||||
|
||||
/// The file fontconfig picks for a subtitle font, as libass asks for it: the
|
||||
/// family with the weight and slant wanted, falling back as libass would to
|
||||
/// whatever fontconfig likes best when the family is not installed.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_subtitle_font(
|
||||
request: &SubtitleRequest,
|
||||
) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
|
||||
let output = std::process::Command::new("fc-match")
|
||||
.args([
|
||||
"-f",
|
||||
"%{file}\\n%{index}\\n%{weight}\\n%{slant}",
|
||||
&fontconfig_pattern(request),
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = std::str::from_utf8(&output.stdout).ok()?;
|
||||
let mut lines = text.lines();
|
||||
let path = std::path::PathBuf::from(lines.next()?);
|
||||
let mut number = || lines.next().and_then(|value| value.parse::<u32>().ok());
|
||||
let index = number().unwrap_or(0);
|
||||
let (weight, slant) = (number().unwrap_or(0), number().unwrap_or(0));
|
||||
// A variable font's named instance is drawn by egui as the default one,
|
||||
// which is upright and regular, whatever fontconfig says it matched.
|
||||
let instance = index != fontconfig_face(index);
|
||||
let face = SubtitleFace {
|
||||
bold: !instance && weight >= FC_WEIGHT_BOLD,
|
||||
italic: !instance && slant > 0,
|
||||
};
|
||||
path.is_file()
|
||||
.then_some((path, fontconfig_face(index), face))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const FC_WEIGHT_BOLD: u32 = 200;
|
||||
|
||||
/// A fontconfig pattern for the request. A family name is free text, and `-`,
|
||||
/// `:`, `,` and `\\` mean something in a pattern, so those are escaped.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn fontconfig_pattern(request: &SubtitleRequest) -> String {
|
||||
let mut pattern = String::new();
|
||||
for character in request.family.chars() {
|
||||
if matches!(character, '-' | ':' | ',' | '\\') {
|
||||
pattern.push('\\');
|
||||
}
|
||||
pattern.push(character);
|
||||
}
|
||||
if request.bold {
|
||||
pattern.push_str(&format!(":weight={FC_WEIGHT_BOLD}"));
|
||||
}
|
||||
if request.italic {
|
||||
pattern.push_str(":slant=100");
|
||||
}
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Elsewhere there is no asking which file a family is, and the preview
|
||||
/// stays in the app's own font.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn system_subtitle_font(_: &SubtitleRequest) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
|
||||
@@ -290,6 +514,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The font families installed here, by name, for the subtitle font picker.
|
||||
///
|
||||
/// `None` until they have been read. The first call starts that on a thread
|
||||
/// of its own and asks for a repaint when it is done: `fc-list` over a large
|
||||
/// font collection takes long enough to be seen as a stalled frame.
|
||||
pub fn font_families(ctx: &egui::Context) -> Option<&'static [String]> {
|
||||
static FAMILIES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
if let Some(families) = FAMILIES.get() {
|
||||
return Some(families);
|
||||
}
|
||||
if !STARTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("font-families".into())
|
||||
.spawn(move || {
|
||||
let _ = FAMILIES.set(system_font_families());
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
tracing::debug!("font families thread: {error}");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_font_families() -> Vec<String> {
|
||||
let Ok(output) = std::process::Command::new("fc-list")
|
||||
.args([":", "family"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
font_families_from_fc_list(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// Elsewhere the picker takes a typed name only. mpv's font lookup there is
|
||||
/// the system's own, so any installed family's name works.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn system_font_families() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// One family per line of `fc-list : family`, sorted and without repeats.
|
||||
///
|
||||
/// A line lists a family's names in every language it has, comma-separated,
|
||||
/// and one family appears once per style. The first name is the one libass
|
||||
/// matches on.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn font_families_from_fc_list(listing: &str) -> Vec<String> {
|
||||
let mut families: Vec<String> = listing
|
||||
.lines()
|
||||
.filter_map(|line| line.split(',').next())
|
||||
.map(|family| family.trim().to_owned())
|
||||
.filter(|family| !family.is_empty() && !family.starts_with('.'))
|
||||
.collect();
|
||||
families.sort_by_key(|family| family.to_lowercase());
|
||||
families.dedup();
|
||||
families
|
||||
}
|
||||
|
||||
/// Every colour the app draws with, as egui wants them.
|
||||
///
|
||||
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
||||
@@ -537,6 +823,11 @@ pub fn ramp_fill(
|
||||
/// scrim that starts opaque and lets the picture out, `true` for one that lets
|
||||
/// the picture *into* the page — which is the one the title backdrop wants,
|
||||
/// since the page is below the band.
|
||||
///
|
||||
/// The ramp itself is white and the colour is the shape's fill, which the GPU
|
||||
/// multiplies in: one texture per direction, however many colours fade —
|
||||
/// a banner tinted per title would otherwise build a ramp for every frame of
|
||||
/// its cross-fade.
|
||||
pub fn fade_shape(
|
||||
ctx: &egui::Context,
|
||||
rect: Rect,
|
||||
@@ -544,8 +835,29 @@ pub fn fade_shape(
|
||||
direction: Direction,
|
||||
arriving: bool,
|
||||
) -> egui::Shape {
|
||||
let texture = alpha_ramp(ctx, color, direction, arriving);
|
||||
textured(rect, CornerRadius::ZERO, &texture)
|
||||
let texture = alpha_ramp(ctx, Color32::WHITE, direction, arriving);
|
||||
let mut shape = egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, color);
|
||||
shape.brush = Some(Arc::new(egui::epaint::Brush {
|
||||
fill_texture_id: texture.id(),
|
||||
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
}));
|
||||
shape.into()
|
||||
}
|
||||
|
||||
/// The page colour tinted by a picture's colour, for the veils over that
|
||||
/// picture: see [`pstr_core::appearance::mood_veil`]. The page colour itself
|
||||
/// while the picture has not arrived.
|
||||
pub fn mood_veil(mood: Option<Color32>) -> Color32 {
|
||||
let palette = palette();
|
||||
let Some(mood) = mood else {
|
||||
return palette.background;
|
||||
};
|
||||
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
|
||||
color(pstr_core::appearance::mood_veil(
|
||||
rgb(palette.background),
|
||||
rgb(mood),
|
||||
palette.light,
|
||||
))
|
||||
}
|
||||
|
||||
/// The one gradient that is not the accent: the top bar, which runs from the
|
||||
@@ -611,6 +923,43 @@ fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::
|
||||
}
|
||||
}
|
||||
|
||||
/// A soft highlight sweeping across a loading skeleton on a diagonal: what
|
||||
/// says the shape is waiting for something rather than empty.
|
||||
///
|
||||
/// The phase runs off the clock rather than per tile, so every skeleton on
|
||||
/// screen sweeps in step, as one surface. Asks for the next frame itself; a
|
||||
/// skeleton is only ever on screen for a moment.
|
||||
pub fn shimmer(painter: &egui::Painter, rect: Rect) {
|
||||
let time = painter.ctx().input(|input| input.time);
|
||||
let phase = (time / f64::from(motion::SHIMMER)).fract() as f32;
|
||||
let span = rect.width() * 0.6;
|
||||
let centre = rect.left() - span + phase * (rect.width() + 2.0 * span);
|
||||
let skew = rect.height() * 0.4;
|
||||
let highlight = if palette().light {
|
||||
Color32::from_black_alpha(10)
|
||||
} else {
|
||||
Color32::from_white_alpha(12)
|
||||
};
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (x, color) in [
|
||||
(centre - span, Color32::TRANSPARENT),
|
||||
(centre, highlight),
|
||||
(centre + span, Color32::TRANSPARENT),
|
||||
] {
|
||||
mesh.colored_vertex(egui::pos2(x + skew, rect.top()), color);
|
||||
mesh.colored_vertex(egui::pos2(x - skew, rect.bottom()), color);
|
||||
}
|
||||
for quad in 0..2 {
|
||||
let i = quad * 2;
|
||||
mesh.add_triangle(i, i + 1, i + 2);
|
||||
mesh.add_triangle(i + 1, i + 3, i + 2);
|
||||
}
|
||||
painter
|
||||
.with_clip_rect(rect.intersect(painter.clip_rect()))
|
||||
.add(mesh);
|
||||
painter.ctx().request_repaint();
|
||||
}
|
||||
|
||||
/// The hover and press film on its own, for a control whose fill was painted by
|
||||
/// somebody else — the selected tab, which sits on the pill.
|
||||
pub fn veil_over(
|
||||
@@ -855,11 +1204,68 @@ const RAMP_STOPS: usize = 64;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_font_family_is_listed_once_by_its_first_name() {
|
||||
let listing = "Noto Sans,Noto Sans Regular\nDejaVu Sans\nnoto sans mono\nNoto Sans,Noto Sans Bold\n\n.LastResort\n";
|
||||
assert_eq!(
|
||||
font_families_from_fc_list(listing),
|
||||
["DejaVu Sans", "Noto Sans", "noto sans mono"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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_system_font_egui_cannot_read_is_left_out() {
|
||||
assert!(system_font_data(INTER.to_vec(), 0).is_some());
|
||||
assert!(system_font_data(INTER.to_vec(), 4).is_none());
|
||||
assert!(system_font_data(b"not a font".to_vec(), 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_font_is_asked_of_fontconfig_with_its_style_and_escaped() {
|
||||
let request = |family: &str, bold, italic| SubtitleRequest {
|
||||
family: family.to_owned(),
|
||||
bold,
|
||||
italic,
|
||||
};
|
||||
assert_eq!(
|
||||
fontconfig_pattern(&request("Noto Sans", false, false)),
|
||||
"Noto Sans"
|
||||
);
|
||||
assert_eq!(
|
||||
fontconfig_pattern(&request("Noto Sans", true, true)),
|
||||
"Noto Sans:weight=200:slant=100"
|
||||
);
|
||||
assert_eq!(
|
||||
fontconfig_pattern(&request("A-B:C,D\\E", false, true)),
|
||||
"A\\-B\\:C\\,D\\\\E:slant=100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subtitle_preview_family_exists_before_any_font_has_loaded() {
|
||||
let fonts = font_definitions(&SystemFonts::default());
|
||||
assert_eq!(
|
||||
fonts.families[&subtitle_family()],
|
||||
fonts.families[&egui::FontFamily::Proportional]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
|
||||
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
||||
for (name, bytes) in [
|
||||
("regular", INTER),
|
||||
("semibold", INTER_SEMIBOLD),
|
||||
("display", INTER_DISPLAY),
|
||||
] {
|
||||
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
||||
let shadowed: Vec<u32> = font
|
||||
.charmap()
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::app::Action;
|
||||
use crate::engine::AccountStatus;
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// What the account section holds between frames.
|
||||
#[derive(Default)]
|
||||
@@ -133,9 +134,10 @@ impl DriveBrowser {
|
||||
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Proton account");
|
||||
ui.label(ui::muted(
|
||||
"Optional. Signed in, your watch history is kept in your own Drive and every device \
|
||||
resumes where another left off, and folders of your Drive — your files, other \
|
||||
devices, what was shared with you — can be added to the library.",
|
||||
"Optional. Signed in, your shares, settings and watch history are kept in your own \
|
||||
Drive, so every device shows the same library and resumes where another left off, \
|
||||
and folders of your Drive — your files, other devices, what was shared with you — \
|
||||
can be added to the library.",
|
||||
));
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
@@ -281,9 +283,11 @@ fn signed_in(
|
||||
.strong(),
|
||||
);
|
||||
let status = match (&panel.sync_error, &panel.synced) {
|
||||
(Some(error), _) => format!("Watch history did not sync: {error}"),
|
||||
(Some(error), _) => format!("Did not sync: {error}"),
|
||||
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
|
||||
(None, None) => "Watch history syncs with your Drive".to_owned(),
|
||||
(None, None) => {
|
||||
"Library, settings and watch history sync through your Drive".to_owned()
|
||||
}
|
||||
};
|
||||
ui.label(ui::muted(status));
|
||||
});
|
||||
@@ -312,13 +316,7 @@ fn signed_in(
|
||||
|
||||
/// "Synced 3 min ago · 2 positions from other devices".
|
||||
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
|
||||
let ago = (now - at).max(0);
|
||||
let when = match ago {
|
||||
0..60 => "just now".to_owned(),
|
||||
60..3600 => format!("{} min ago", ago / 60),
|
||||
_ => format!("{} h ago", ago / 3600),
|
||||
};
|
||||
let mut line = format!("Synced {when}");
|
||||
let mut line = format!("Synced {}", ui::ago(at, now));
|
||||
if report.applied > 0 {
|
||||
line.push_str(&format!(
|
||||
" · {} from other devices",
|
||||
@@ -382,7 +380,7 @@ pub fn browser(
|
||||
if !browser.trail.is_empty()
|
||||
&& ui
|
||||
.button(egui_phosphor::regular::ARROW_LEFT)
|
||||
.on_hover_text("Up a folder")
|
||||
.hint("Up a folder")
|
||||
.clicked()
|
||||
{
|
||||
browser.up(actions);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Inspectable desktop download queue.
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadState};
|
||||
use crate::{theme, ui};
|
||||
|
||||
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
}
|
||||
}
|
||||
DownloadState::Completed => {
|
||||
// The point of a download is to watch it, and the library is a
|
||||
// detour from here — offline most of all.
|
||||
if ui::accent_button(ui, "Play").clicked() {
|
||||
actions.push(Action::PlayResuming(item.target.clone()));
|
||||
}
|
||||
if ui.button("Open").on_hover_text("Go to its title").clicked() {
|
||||
actions.push(Action::Goto(Page::Title(item.target.title_key.clone())));
|
||||
}
|
||||
if ui
|
||||
.button("Delete")
|
||||
.on_hover_text("Delete the offline copy; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key(), false));
|
||||
actions.push(Action::DeleteOffline(vec![key()], item.target.caption()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||
|
||||
use crate::app::{Action, Page};
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, Art};
|
||||
|
||||
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||
@@ -161,7 +162,7 @@ fn row(
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.on_hover_text(if episode.is_watched() {
|
||||
.hint(if episode.is_watched() {
|
||||
"Remove from history — marks it unwatched"
|
||||
} else {
|
||||
"Remove from history — forgets where you stopped"
|
||||
|
||||
@@ -55,8 +55,8 @@ pub fn show(
|
||||
.color(theme::text()),
|
||||
);
|
||||
ui.label(ui::muted(format!(
|
||||
"Everything {} answers with, in its own order — nothing is scored here.",
|
||||
provider.label()
|
||||
"Everything {} answer with, in their own order — nothing is scored here.",
|
||||
pstr_meta::service::chain_label(provider)
|
||||
)));
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
@@ -130,12 +130,29 @@ fn current_row(
|
||||
)));
|
||||
// Only worth offering when there is something to undo: with no
|
||||
// row stored, "clear" would delete nothing.
|
||||
if ui
|
||||
//
|
||||
// A match chosen by hand is asked about first: it is work the
|
||||
// next match run does not redo.
|
||||
let asking = ui.id().with(("forget", &matcher.title_key));
|
||||
if ui.data(|data| data.get_temp::<bool>(asking).unwrap_or(false)) {
|
||||
ui.label(ui::muted("Forget the match you chose?"));
|
||||
if ui::danger_button(ui, "Forget").clicked() {
|
||||
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
}
|
||||
if ui.button("Keep").clicked() {
|
||||
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||
}
|
||||
} else if ui
|
||||
.button("Clear")
|
||||
.on_hover_text("Forget this match and let the next match run decide again")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
if *manual {
|
||||
ui.data_mut(|data| data.insert_temp(asking, true));
|
||||
} else {
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((None, _)) => {
|
||||
@@ -262,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
|
||||
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||
}
|
||||
|
||||
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
||||
/// simply absent.
|
||||
/// `Film · 2017 · 26 episodes · ★ 8.2 · AniList`, with whatever the provider
|
||||
/// left out simply absent. The provider last, because one search asks more
|
||||
/// than one, and the same show can come back from each.
|
||||
fn facts(found: &TitleMetadata) -> String {
|
||||
let mut parts: Vec<String> = vec![
|
||||
match found.kind {
|
||||
@@ -281,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
|
||||
if let Some(rating) = found.rating {
|
||||
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||
}
|
||||
parts.push(found.provider.label().to_string());
|
||||
parts.join(" · ")
|
||||
}
|
||||
@@ -23,8 +23,10 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
|
||||
use pstr_core::library::{Episode, Title};
|
||||
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
||||
use crate::theme;
|
||||
use widgets::Named;
|
||||
|
||||
/// Everything the pages need to draw one title's picture.
|
||||
///
|
||||
@@ -79,6 +81,17 @@ impl Art<'_> {
|
||||
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
||||
}
|
||||
|
||||
/// A provider's picture that belongs to no one title's tile — a cast
|
||||
/// member's portrait — fetched like a poster the first time it is asked
|
||||
/// for.
|
||||
pub fn picture(&mut self, url: &str) -> Option<egui::TextureHandle> {
|
||||
let key = format!("picture:{url}");
|
||||
let engine = self.engine;
|
||||
let url = url.to_owned();
|
||||
self.posters
|
||||
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||
}
|
||||
|
||||
/// The name to show for `title`. See [`title_name`].
|
||||
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
|
||||
title_name(title, self.metadata, self.names)
|
||||
@@ -106,7 +119,9 @@ impl Art<'_> {
|
||||
}
|
||||
|
||||
/// Art for a strip across the whole window, at full size: fanart, else
|
||||
/// AniList's banner, else whatever [`Art::of`] has.
|
||||
/// AniList's banner, else the poster blurred into a wash of its colours —
|
||||
/// see [`crate::engine::Engine::request_ambient`] — else whatever
|
||||
/// [`Art::of`] has.
|
||||
///
|
||||
/// Its own texture rather than the tile's: a 640 px tile picture
|
||||
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
|
||||
@@ -127,10 +142,61 @@ impl Art<'_> {
|
||||
{
|
||||
return Some((texture, ArtShape::Landscape));
|
||||
}
|
||||
} else if let Some(url) = self.poster_url(title) {
|
||||
let key = ambient_key(&title.key);
|
||||
let engine = self.engine;
|
||||
if let Some(texture) = self
|
||||
.posters
|
||||
.texture(&key, || engine.request_ambient(key.clone(), url))
|
||||
{
|
||||
return Some((texture, ArtShape::Landscape));
|
||||
}
|
||||
}
|
||||
self.of(title)
|
||||
}
|
||||
|
||||
/// The colour of whatever [`Art::wide`] is drawing for `title`, once it
|
||||
/// has arrived — what that title's veils are tinted with. See
|
||||
/// [`theme::mood_veil`].
|
||||
pub fn mood(&self, title: &Title) -> Option<egui::Color32> {
|
||||
[wide_key, ambient_key, poster_key]
|
||||
.into_iter()
|
||||
.map(|key| key(&title.key))
|
||||
// The tile's own picture last: what `wide` falls back on.
|
||||
.chain(std::iter::once(title.key.clone()))
|
||||
.find_map(|key| self.posters.mood(&key))
|
||||
}
|
||||
|
||||
/// Whether `title` has a picture made for a strip across the window, rather
|
||||
/// than [`Art::wide`] standing one in.
|
||||
pub fn has_wide_art(&self, title: &Title) -> bool {
|
||||
self.metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.is_some_and(|metadata| metadata.wide_art().is_some())
|
||||
}
|
||||
|
||||
fn poster_url(&self, title: &Title) -> Option<String> {
|
||||
self.metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.and_then(|metadata| metadata.poster_url.clone())
|
||||
}
|
||||
|
||||
/// The title's name as lettering, trimmed to it, when the provider has
|
||||
/// one and it has arrived. Drawn over wide art in place of the name.
|
||||
pub fn logo(&mut self, title: &Title) -> Option<egui::TextureHandle> {
|
||||
let url = self
|
||||
.metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.and_then(|metadata| metadata.logo_url.clone())?;
|
||||
let key = logo_key(&title.key);
|
||||
let engine = self.engine;
|
||||
self.posters
|
||||
.texture(&key, || engine.request_logo(key.clone(), url))
|
||||
}
|
||||
|
||||
/// The picture for a title, and how to fit it.
|
||||
///
|
||||
/// Provider artwork first, then Proton's still, then nothing. That order is
|
||||
@@ -161,6 +227,10 @@ impl Art<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The central panel's margin around every page. Anything drawn edge to edge —
|
||||
/// the library's banner, the title page's band — reaches past it.
|
||||
pub const PAGE_MARGIN: egui::Vec2 = egui::vec2(18.0, 12.0);
|
||||
|
||||
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
|
||||
/// title key itself.
|
||||
pub fn poster_key(title_key: &str) -> String {
|
||||
@@ -172,6 +242,74 @@ pub fn wide_key(title_key: &str) -> String {
|
||||
format!("wide:{title_key}")
|
||||
}
|
||||
|
||||
/// The texture key of a title's poster blurred for a band.
|
||||
pub fn ambient_key(title_key: &str) -> String {
|
||||
format!("ambient:{title_key}")
|
||||
}
|
||||
|
||||
/// A title's logo in place of its name in Display type, when there is one to
|
||||
/// draw; `None` leaves the caller to set the name as it does.
|
||||
///
|
||||
/// At most `max_height` tall and as wide as the space allows, up to 420 px —
|
||||
/// a logo the width of the column shouts. A light palette keeps the name: a
|
||||
/// clear logo is lettering made for dark art, white as often as not, and over
|
||||
/// a near-white veil it is gone.
|
||||
pub fn logo_heading(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
title: &Title,
|
||||
max_height: f32,
|
||||
) -> Option<egui::Response> {
|
||||
if theme::palette().light {
|
||||
return None;
|
||||
}
|
||||
let texture = art.logo(title);
|
||||
// From nothing rather than from the name it replaces: the name is gone the
|
||||
// frame the logo is ready, and a logo easing in reads as it arriving.
|
||||
let shown = ui.ctx().animate_bool_with_time(
|
||||
ui.id().with(("logo", &title.key)),
|
||||
texture.is_some(),
|
||||
theme::motion::ARRIVE,
|
||||
);
|
||||
let texture = texture?;
|
||||
let aspect = texture.aspect_ratio().max(0.1);
|
||||
let width = ui.available_width().min(420.0).min(max_height * aspect);
|
||||
let size = egui::vec2(width, width / aspect);
|
||||
Some(
|
||||
ui.add(
|
||||
egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)),
|
||||
)
|
||||
.on_hover_text(art.name(title)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The texture key of a title's logo.
|
||||
pub fn logo_key(title_key: &str) -> String {
|
||||
format!("logo:{title_key}")
|
||||
}
|
||||
|
||||
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
|
||||
pub fn format_runtime(minutes: u32) -> String {
|
||||
match (minutes / 60, minutes % 60) {
|
||||
(0, minutes) => format!("{minutes} min"),
|
||||
(hours, 0) => format!("{hours} h"),
|
||||
(hours, minutes) => format!("{hours} h {minutes} min"),
|
||||
}
|
||||
}
|
||||
|
||||
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
|
||||
/// the Unix time `at` was.
|
||||
pub fn ago(at: i64, now: i64) -> String {
|
||||
let ago = (now - at).max(0);
|
||||
match ago {
|
||||
0..60 => "just now".to_owned(),
|
||||
60..3600 => format!("{} min ago", ago / 60),
|
||||
3600..86_400 => format!("{} h ago", ago / 3600),
|
||||
86_400..172_800 => "1 day ago".to_owned(),
|
||||
_ => format!("{} days ago", ago / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
/// `1:03:47`, or `4:12` for anything under an hour.
|
||||
pub fn format_time(seconds: f64) -> String {
|
||||
if !seconds.is_finite() || seconds < 0.0 {
|
||||
@@ -192,6 +330,18 @@ pub fn format_speed(speed: f64) -> String {
|
||||
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
|
||||
}
|
||||
|
||||
/// `+0.3 s`, `−1.2 s` or `0 s`: a subtitle delay, signed so the direction
|
||||
/// reads without knowing which way mpv counts.
|
||||
pub fn format_delay(seconds: f64) -> String {
|
||||
if seconds.abs() < 0.05 {
|
||||
"0 s".to_owned()
|
||||
} else if seconds > 0.0 {
|
||||
format!("+{seconds:.1} s")
|
||||
} else {
|
||||
format!("−{:.1} s", -seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// `1.4 GiB`, for what a file costs to watch.
|
||||
pub fn format_size(bytes: i64) -> String {
|
||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||
@@ -230,11 +380,53 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// The button beside the accent one: the same shape, in a film of the text
|
||||
/// colour, so "Start over" next to "Resume" reads as the alternative rather
|
||||
/// than as a second, unrelated kind of control.
|
||||
pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// My List's button: `+ My List` to put the title on it, `✓ My List` once it
|
||||
/// is, which takes it off again.
|
||||
pub fn list_button(ui: &mut egui::Ui, key: &str, on_list: bool, actions: &mut Vec<Action>) {
|
||||
let label = if on_list {
|
||||
format!("{} My List", egui_phosphor::regular::CHECK)
|
||||
} else {
|
||||
format!("{} My List", egui_phosphor::regular::PLUS)
|
||||
};
|
||||
let response = tonal_button(ui, &label).on_hover_text(if on_list {
|
||||
"On My List. Click to take it off"
|
||||
} else {
|
||||
"Put it on My List, to find it again on every device"
|
||||
});
|
||||
if response.clicked() {
|
||||
actions.push(Action::SetOnList {
|
||||
key: key.to_owned(),
|
||||
on: !on_list,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A button laid over the picture: dark glass and white ink, whatever the
|
||||
/// theme, because what is under it is a video frame and not the page.
|
||||
pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, Fill::Glass, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
|
||||
///
|
||||
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
||||
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
|
||||
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
///
|
||||
/// Empty and with the caret in it, it offers the `recent` searches under it.
|
||||
/// Picking one fills the box; the return says the viewer asked to forget them.
|
||||
pub fn search_field(
|
||||
ui: &mut egui::Ui,
|
||||
search: &mut String,
|
||||
focus: bool,
|
||||
recent: &[String],
|
||||
) -> bool {
|
||||
let id = egui::Id::new("library-search");
|
||||
let width = 240.0;
|
||||
let height = ui.spacing().interact_size.y;
|
||||
@@ -273,7 +465,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.hint_text(if has_focus {
|
||||
"Titles and filenames"
|
||||
"Titles, episodes, people"
|
||||
} else {
|
||||
"Search Ctrl+F"
|
||||
})
|
||||
@@ -311,12 +503,62 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
);
|
||||
if response
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("Clear (Esc)")
|
||||
.hint("Clear (Esc)")
|
||||
.clicked()
|
||||
{
|
||||
search.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Kept up while the pointer is over it as well as while the box has the
|
||||
// caret: pressing on it takes the caret away before the click lands.
|
||||
let popup = id.with("recent");
|
||||
let over = ui
|
||||
.data(|data| data.get_temp::<bool>(popup))
|
||||
.unwrap_or(false);
|
||||
let mut forget = false;
|
||||
if search.is_empty() && !recent.is_empty() && (response.has_focus() || over) {
|
||||
let mut picked = None;
|
||||
let shown = egui::Area::new(popup)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(outer.left_bottom() + Vec2::new(0.0, 6.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(width - 12.0);
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich("Recent searches")
|
||||
.color(theme::muted()),
|
||||
);
|
||||
for query in recent {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::selectable(false, query.as_str())
|
||||
.frame_when_inactive(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
picked = Some(query.clone());
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
forget = ui
|
||||
.add(
|
||||
egui::Button::selectable(false, muted("Clear recent searches"))
|
||||
.frame_when_inactive(false),
|
||||
)
|
||||
.clicked();
|
||||
});
|
||||
});
|
||||
let over = shown.response.contains_pointer();
|
||||
ui.data_mut(|data| data.insert_temp(popup, over && !forget && picked.is_none()));
|
||||
if let Some(query) = picked {
|
||||
*search = query;
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|data| data.remove::<bool>(popup));
|
||||
}
|
||||
forget
|
||||
}
|
||||
|
||||
/// A thin bar in the accent, for how far something has got.
|
||||
@@ -374,6 +616,19 @@ pub enum Answer {
|
||||
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
||||
/// the click.
|
||||
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
||||
confirm_with(ctx, id, heading, body, verb, true)
|
||||
}
|
||||
|
||||
/// [`confirm`], for a question whose yes loses nothing — a large download —
|
||||
/// and so is the accent rather than the danger colour.
|
||||
pub fn confirm_with(
|
||||
ctx: &egui::Context,
|
||||
id: &str,
|
||||
heading: &str,
|
||||
body: &str,
|
||||
verb: &str,
|
||||
destroys: bool,
|
||||
) -> Answer {
|
||||
let mut answer = Answer::Pending;
|
||||
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||
.frame(
|
||||
@@ -394,7 +649,12 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
|
||||
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||
ui.add_space(theme::space::XL);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if danger_button(ui, verb).clicked() {
|
||||
let yes = if destroys {
|
||||
danger_button(ui, verb)
|
||||
} else {
|
||||
accent_button(ui, verb)
|
||||
};
|
||||
if yes.clicked() {
|
||||
answer = Answer::Confirmed;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
@@ -418,6 +678,10 @@ enum Fill {
|
||||
Ink,
|
||||
/// Nothing at rest, the hover colour under the pointer.
|
||||
None,
|
||||
/// A film of the text colour over whatever is behind it.
|
||||
Tonal,
|
||||
/// Dark glass over the picture.
|
||||
Glass,
|
||||
}
|
||||
|
||||
/// A row of tabs with one accent pill that slides between them.
|
||||
@@ -498,6 +762,7 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
||||
);
|
||||
|
||||
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||
let response = response.named(text);
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return response;
|
||||
}
|
||||
@@ -537,6 +802,35 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
||||
}
|
||||
theme::muted().lerp_to_gamma(theme::text(), hover)
|
||||
}
|
||||
Fill::Tonal => {
|
||||
let film = if theme::palette().light {
|
||||
Color32::from_black_alpha(16)
|
||||
} else {
|
||||
Color32::from_white_alpha(22)
|
||||
};
|
||||
ui.painter().rect_filled(rect, radius, film);
|
||||
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||
theme::text()
|
||||
}
|
||||
Fill::Glass => {
|
||||
ui.painter()
|
||||
.rect_filled(rect, radius, Color32::from_black_alpha(140));
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
radius,
|
||||
Stroke::new(1.0, Color32::from_white_alpha(18)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||
Color32::WHITE
|
||||
}
|
||||
};
|
||||
// A disabled control is drawn at part strength: the ink is what says what
|
||||
// the button is, so that is what fades.
|
||||
let ink = if ui.is_enabled() {
|
||||
ink
|
||||
} else {
|
||||
ink.gamma_multiply(0.4)
|
||||
};
|
||||
|
||||
let position = rect.center() - galley.size() / 2.0;
|
||||
@@ -595,6 +889,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||
let response = if card.subtitle.is_empty() {
|
||||
response.named(card.name)
|
||||
} else {
|
||||
response.named(format!("{}, {}", card.name, card.subtitle))
|
||||
};
|
||||
// Reached with the arrow keys or Tab: bring it into view, before the
|
||||
// early return below would skip a tile that is just off screen.
|
||||
if response.gained_focus() {
|
||||
@@ -604,18 +903,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
return response;
|
||||
}
|
||||
|
||||
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||
let resting = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||
// Hover is a value, not a flag: the border, the lift and the shadow all
|
||||
// ride it, so they arrive together rather than each snapping on its own.
|
||||
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||
// the frame rate — and it needs no state kept here.
|
||||
// Focus lights a tile the way the pointer does, so the keyboard can be
|
||||
// followed across the grid.
|
||||
// Focus lifts a tile the way the pointer does, and adds a ring of its own,
|
||||
// so the keyboard can be followed across the grid.
|
||||
let hover = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("hover"),
|
||||
response.hovered() || response.has_focus(),
|
||||
theme::motion::HOVER,
|
||||
);
|
||||
let focus = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("focus"),
|
||||
response.has_focus(),
|
||||
theme::motion::HOVER,
|
||||
);
|
||||
// The picture grows towards the viewer into the gap around it, which is
|
||||
// what says "this one" across a shelf of near-identical rectangles. The
|
||||
// name under it stays put, so the row's text never wobbles.
|
||||
let image_rect = resting.expand2(resting.size() * lift_per_side(width) * hover);
|
||||
// The picture arrives whenever its download finishes, several frames after
|
||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||
// letters popping into stills into one settle.
|
||||
@@ -731,9 +1039,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
if focus > 0.0 {
|
||||
painter.rect_stroke(
|
||||
image_rect.expand(3.0),
|
||||
CornerRadius::same(theme::radius::MD + 3),
|
||||
Stroke::new(2.0, theme::text().gamma_multiply(focus)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
|
||||
let text_rect = Rect::from_min_size(
|
||||
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
|
||||
resting.left_bottom() + Vec2::new(0.0, 6.0),
|
||||
Vec2::new(width, text_height - 6.0),
|
||||
);
|
||||
let painter = painter.with_clip_rect(text_rect);
|
||||
@@ -764,6 +1080,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Every sideways row in the app: the shelves on the home page and the
|
||||
/// franchise on a title's page.
|
||||
///
|
||||
/// What runs past either edge fades into the page rather than being cut off
|
||||
/// by it, so a row says there is more without a scroll bar saying it. Under
|
||||
/// the pointer, a round button at each edge with more beyond it pages the row
|
||||
/// by most of its width: a mouse has no sideways wheel, and the bar was the
|
||||
/// only other way across.
|
||||
///
|
||||
/// The row is padded by half a gap all round inside its scroll area, and the
|
||||
/// area reaches the same half gap past the column on either side, so a lifted
|
||||
/// tile is not clipped by the area and the tiles still line up with the
|
||||
/// headings above them.
|
||||
pub fn shelf<R>(
|
||||
ui: &mut egui::Ui,
|
||||
id_salt: impl std::hash::Hash + std::fmt::Debug,
|
||||
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||
) -> R {
|
||||
let half = (theme::CARD_GAP / 2.0) as i8;
|
||||
let id = ui.id().with(("shelf", &id_salt));
|
||||
let page = id.with("page");
|
||||
let pending: Option<f32> = ui.ctx().data_mut(|data| data.remove_temp(page));
|
||||
let output = egui::Frame::new()
|
||||
.outer_margin(egui::Margin {
|
||||
left: -half,
|
||||
right: -half,
|
||||
top: -half,
|
||||
bottom: -half,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt(id)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
if let Some(delta) = pending {
|
||||
ui.scroll_with_delta_animation(
|
||||
Vec2::new(delta, 0.0),
|
||||
egui::style::ScrollAnimation::duration(SHELF_PAGE_SECONDS),
|
||||
);
|
||||
}
|
||||
egui::Frame::new()
|
||||
.inner_margin(egui::Margin::same(half))
|
||||
.show(ui, add)
|
||||
.inner
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
|
||||
let rect = output.inner_rect;
|
||||
let offset = output.state.offset.x;
|
||||
let overflow = (output.content_size.x - rect.width()).max(0.0);
|
||||
let ends = [
|
||||
(offset > 1.0, false, egui_phosphor::regular::CARET_LEFT, 1.0),
|
||||
(
|
||||
offset < overflow - 1.0,
|
||||
true,
|
||||
egui_phosphor::regular::CARET_RIGHT,
|
||||
-1.0,
|
||||
),
|
||||
];
|
||||
let pointer_in = ui.rect_contains_pointer(rect);
|
||||
let painter = ui.painter_at(rect);
|
||||
for (more, right, icon, direction) in ends {
|
||||
if !more {
|
||||
continue;
|
||||
}
|
||||
let strip = if right {
|
||||
Rect::from_min_max(egui::pos2(rect.right() - SHELF_FADE, rect.top()), rect.max)
|
||||
} else {
|
||||
Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(rect.left() + SHELF_FADE, rect.bottom()),
|
||||
)
|
||||
};
|
||||
painter.add(theme::fade_shape(
|
||||
ui.ctx(),
|
||||
strip,
|
||||
theme::background(),
|
||||
theme::Direction::Horizontal,
|
||||
right,
|
||||
));
|
||||
|
||||
let button_id = id.with(right);
|
||||
let shown = ui
|
||||
.ctx()
|
||||
.animate_bool_with_time(button_id, pointer_in, theme::motion::HOVER);
|
||||
if shown == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let centre = egui::pos2(
|
||||
if right {
|
||||
rect.right() - SHELF_BUTTON / 2.0 - theme::space::M
|
||||
} else {
|
||||
rect.left() + SHELF_BUTTON / 2.0 + theme::space::M
|
||||
},
|
||||
rect.center().y,
|
||||
);
|
||||
let button = Rect::from_center_size(centre, Vec2::splat(SHELF_BUTTON));
|
||||
let response = ui
|
||||
.interact(button, button_id.with("button"), Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let response = response.named(if right { "Scroll right" } else { "Scroll left" });
|
||||
let lit = if response.hovered() { 1.0 } else { 0.0 };
|
||||
painter.circle_filled(
|
||||
centre,
|
||||
SHELF_BUTTON / 2.0,
|
||||
Color32::from_black_alpha((150.0 + 60.0 * lit) as u8).gamma_multiply(shown),
|
||||
);
|
||||
painter.text(
|
||||
centre,
|
||||
Align2::CENTER_CENTER,
|
||||
icon,
|
||||
egui::FontId::proportional(18.0),
|
||||
Color32::WHITE.gamma_multiply(shown),
|
||||
);
|
||||
if response.clicked() {
|
||||
let delta = direction * rect.width() * SHELF_PAGE;
|
||||
ui.ctx().data_mut(|data| data.insert_temp(page, delta));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
output.inner
|
||||
}
|
||||
|
||||
/// How wide the fade at a shelf's edge is.
|
||||
const SHELF_FADE: f32 = 56.0;
|
||||
/// The diameter of a shelf's paging buttons.
|
||||
const SHELF_BUTTON: f32 = 40.0;
|
||||
/// How much of a shelf's width one page moves: most of it, so the tile that
|
||||
/// was cut by the edge is whole on the next page.
|
||||
const SHELF_PAGE: f32 = 0.85;
|
||||
const SHELF_PAGE_SECONDS: f32 = 0.35;
|
||||
|
||||
/// How far a lifted tile grows past each edge, as a fraction of its size:
|
||||
/// [`theme::TILE_LIFT`], but never so far that it reaches into the gap's other
|
||||
/// half, which belongs to the neighbour. A wide window makes wide tiles.
|
||||
fn lift_per_side(width: f32) -> f32 {
|
||||
(theme::TILE_LIFT / 2.0).min((theme::CARD_GAP / 2.0 - 1.0) / width.max(1.0))
|
||||
}
|
||||
|
||||
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
|
||||
///
|
||||
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
|
||||
@@ -879,8 +1335,32 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_subtitle_delay_reads_with_its_direction() {
|
||||
assert_eq!(super::format_delay(0.3), "+0.3 s");
|
||||
assert_eq!(super::format_delay(-1.2), "−1.2 s");
|
||||
assert_eq!(super::format_delay(0.0000001), "0 s");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_lifted_tile_never_reaches_its_neighbour_however_wide_the_window() {
|
||||
for available in [400.0, 1280.0, 1919.0, 2560.0, 3840.0] {
|
||||
for minimum in [theme::CARD_WIDTH, theme::POSTER_WIDTH] {
|
||||
let width = columns_of(available, minimum).width;
|
||||
assert!(width * lift_per_side(width) < theme::CARD_GAP / 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_time_reads_in_hours_and_minutes() {
|
||||
assert_eq!(format_runtime(24), "24 min");
|
||||
assert_eq!(format_runtime(120), "2 h");
|
||||
assert_eq!(format_runtime(124), "2 h 4 min");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_are_formatted_by_length() {
|
||||
assert_eq!(format_time(0.0), "0:00");
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||
use pstr_core::prefs::{Colour, PlaybackPrefs, SubtitleBackdrop, SubtitleStyle};
|
||||
use pstr_player::LANGUAGES;
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs, TEXT_SIZE_CHOICES};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// The settings that are a single value rather than a form.
|
||||
///
|
||||
@@ -19,9 +22,11 @@ use crate::ui;
|
||||
/// they are read-only, and passing them one by one is what pushed this
|
||||
/// function past the argument count anybody can read.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Prefs {
|
||||
pub struct Prefs<'a> {
|
||||
pub autoplay: bool,
|
||||
pub auto_skip: bool,
|
||||
/// The languages, read from here.
|
||||
pub playback: &'a PlaybackPrefs,
|
||||
pub appearance: Appearance,
|
||||
pub desktop: DesktopPrefs,
|
||||
/// What the streaming cache holds, once the shares are open.
|
||||
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
prefs: Prefs,
|
||||
prefs: Prefs<'_>,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -51,8 +56,9 @@ pub fn show(
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width);
|
||||
ui.add_space(theme::space::M);
|
||||
appearance_form(ui, prefs.appearance, actions);
|
||||
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
||||
playback_form(ui, prefs, actions);
|
||||
subtitle_form(ui, &prefs.playback.subtitle_style, actions);
|
||||
storage_form(ui, prefs, actions);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
about(ui);
|
||||
@@ -66,7 +72,12 @@ pub fn show(
|
||||
/// Every control here repaints the whole window the moment it is clicked —
|
||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||
/// only changed a small square would be asking someone to imagine the result.
|
||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||
fn appearance_form(
|
||||
ui: &mut egui::Ui,
|
||||
appearance: Appearance,
|
||||
desktop: DesktopPrefs,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||
@@ -75,6 +86,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for flavor in Flavor::ALL {
|
||||
if palette_card(ui, appearance, flavor)
|
||||
.named(flavor.label())
|
||||
.on_hover_text(flavor.description())
|
||||
.clicked()
|
||||
&& appearance.flavor != flavor
|
||||
@@ -102,6 +114,27 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
}
|
||||
});
|
||||
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Text size",
|
||||
Some("Everything grows with it. Kept on this computer only."),
|
||||
|ui| {
|
||||
let choices: Vec<(u16, String)> = TEXT_SIZE_CHOICES
|
||||
.iter()
|
||||
.map(|&percent| (percent, format!("{percent}%")))
|
||||
.collect();
|
||||
let labels: Vec<(u16, &str)> = choices
|
||||
.iter()
|
||||
.map(|(percent, label)| (*percent, label.as_str()))
|
||||
.collect();
|
||||
if let Some(text_size) = ui::widgets::segmented(ui, desktop.text_size, &labels) {
|
||||
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
|
||||
text_size,
|
||||
..desktop
|
||||
}));
|
||||
}
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Library tiles",
|
||||
@@ -265,22 +298,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
||||
}
|
||||
}
|
||||
response
|
||||
.on_hover_text(accent.label())
|
||||
.hint(accent.label())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// The playback settings that are not per-file.
|
||||
///
|
||||
/// Volume, audio language, subtitle language and speed are all set from the
|
||||
/// player itself, where a viewer can hear the result.
|
||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
/// Volume and speed are set from the player itself, where a viewer can hear
|
||||
/// the result. The languages are here as well as there: what a first episode
|
||||
/// opens with is decided before there is a player to choose from.
|
||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Playback",
|
||||
Some(
|
||||
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
|
||||
"Volume and speed are set from the player. A language chosen in the player for one show is kept for that show.",
|
||||
),
|
||||
|ui| {
|
||||
languages(ui, prefs.playback, actions);
|
||||
let mut autoplay = prefs.autoplay;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
@@ -322,8 +357,442 @@ 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// How subtitles look, with a sample line to judge it by.
|
||||
///
|
||||
/// Every change reaches a playing file as it is made, so the film itself is
|
||||
/// the other preview. A styled track takes none of it unless it is asked to;
|
||||
/// see [`SubtitleStyle`].
|
||||
fn subtitle_form(ui: &mut egui::Ui, style: &SubtitleStyle, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Subtitles",
|
||||
Some(
|
||||
"Changes show on the playing video straight away. Plain subtitles (SRT) take all of this; styled ones (ASS) keep their own look unless you restyle them below.",
|
||||
),
|
||||
|ui| {
|
||||
let mut chosen = style.clone();
|
||||
ui.add_space(theme::space::L);
|
||||
subtitle_preview(ui, &chosen);
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
ui::widgets::settings_row(ui, "Size", None, |ui| {
|
||||
percent_slider(
|
||||
ui,
|
||||
&mut chosen.scale,
|
||||
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
|
||||
);
|
||||
});
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Position",
|
||||
Some("How far up the picture the line sits."),
|
||||
|ui| subtitle_position_slider(ui, &mut chosen.position),
|
||||
);
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Font",
|
||||
Some(if cfg!(target_os = "linux") {
|
||||
"Any font installed here."
|
||||
} else {
|
||||
"Any font installed here. The sample above is drawn in the app's own font."
|
||||
}),
|
||||
|ui| {
|
||||
ui::widgets::toggle(ui, &mut chosen.italic, "Italic");
|
||||
ui::widgets::toggle(ui, &mut chosen.bold, "Bold");
|
||||
font_picker(ui, &mut chosen.font);
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(ui, "Text colour", None, |ui| {
|
||||
colour_button(ui, &mut chosen.colour);
|
||||
});
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Background",
|
||||
Some("A box reads on bright scenes; an outline hides less of the picture."),
|
||||
|ui| {
|
||||
let choices =
|
||||
SubtitleBackdrop::ALL.map(|backdrop| (backdrop, backdrop.label()));
|
||||
if let Some(backdrop) = ui::widgets::segmented(ui, chosen.backdrop, &choices) {
|
||||
chosen.backdrop = backdrop;
|
||||
}
|
||||
},
|
||||
);
|
||||
match chosen.backdrop {
|
||||
SubtitleBackdrop::Outline => {
|
||||
ui::widgets::settings_row(ui, "Outline", None, |ui| {
|
||||
colour_button(ui, &mut chosen.outline_colour);
|
||||
ui.add(
|
||||
egui::Slider::new(
|
||||
&mut chosen.outline_size,
|
||||
0.0..=SubtitleStyle::MAX_OUTLINE,
|
||||
)
|
||||
.step_by(0.05)
|
||||
.fixed_decimals(1),
|
||||
);
|
||||
});
|
||||
}
|
||||
SubtitleBackdrop::Box => {
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Box colour",
|
||||
Some("Its opacity is how much of the picture shows through."),
|
||||
|ui| colour_button(ui, &mut chosen.box_colour),
|
||||
);
|
||||
}
|
||||
}
|
||||
ui::widgets::settings_row(ui, "Shadow", Some("How far it falls. 0 is none."), |ui| {
|
||||
colour_button(ui, &mut chosen.shadow_colour);
|
||||
ui.add(
|
||||
egui::Slider::new(&mut chosen.shadow_offset, 0.0..=SubtitleStyle::MAX_SHADOW)
|
||||
.step_by(0.1)
|
||||
.fixed_decimals(1),
|
||||
);
|
||||
});
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Blur",
|
||||
Some("Softens the edges. Not shown in the sample."),
|
||||
|ui| {
|
||||
ui.add(
|
||||
egui::Slider::new(&mut chosen.blur, 0.0..=SubtitleStyle::MAX_BLUR)
|
||||
.step_by(0.1)
|
||||
.fixed_decimals(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Restyle styled subtitles",
|
||||
Some(
|
||||
"Draw styled (ASS) dialogue in this style too, size and position included. Signs, songs and karaoke can come out wrong.",
|
||||
),
|
||||
|ui| {
|
||||
ui::widgets::toggle(ui, &mut chosen.restyle_styled, "");
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(ui, "Reset", Some("Back to mpv's own look."), |ui| {
|
||||
if ui
|
||||
.add_enabled(
|
||||
*style != SubtitleStyle::default(),
|
||||
egui::Button::new("Reset"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
chosen = SubtitleStyle::default();
|
||||
}
|
||||
});
|
||||
|
||||
if chosen != *style {
|
||||
actions.push(Action::SetSubtitleStyle(chosen.sanitized()));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A scale as a percentage slider, 100% being mpv's own size.
|
||||
pub(crate) fn percent_slider(
|
||||
ui: &mut egui::Ui,
|
||||
scale: &mut f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
) -> egui::Response {
|
||||
let mut percent = *scale * 100.0;
|
||||
let response = ui.add(
|
||||
egui::Slider::new(&mut percent, range.start() * 100.0..=range.end() * 100.0)
|
||||
.step_by(5.0)
|
||||
.suffix("%")
|
||||
.fixed_decimals(0),
|
||||
);
|
||||
*scale = percent / 100.0;
|
||||
response
|
||||
}
|
||||
|
||||
/// Where the line sits, 0 at the top of the picture and 100 at the bottom —
|
||||
/// shown the other way up, as height above the bottom edge, which is how
|
||||
/// anyone would describe moving subtitles up.
|
||||
pub(crate) fn subtitle_position_slider(ui: &mut egui::Ui, position: &mut f64) -> egui::Response {
|
||||
let mut height = 100.0 - *position;
|
||||
let response = ui.add(
|
||||
egui::Slider::new(&mut height, 0.0..=90.0)
|
||||
.step_by(1.0)
|
||||
.suffix("% up")
|
||||
.fixed_decimals(0),
|
||||
);
|
||||
*position = 100.0 - height;
|
||||
response
|
||||
}
|
||||
|
||||
/// A swatch that opens a colour wheel with an opacity slider.
|
||||
fn colour_button(ui: &mut egui::Ui, colour: &mut Colour) {
|
||||
let [r, g, b, a] = colour.to_array();
|
||||
let mut picked = egui::Color32::from_rgba_unmultiplied(r, g, b, a);
|
||||
// The `Color32` form rather than `Hsva`, because egui remembers the hue
|
||||
// behind it: rebuilt from RGB each frame, black and grey have none, and
|
||||
// the wheel would snap back to red under the mouse.
|
||||
let response = egui::color_picker::color_edit_button_srgba(
|
||||
ui,
|
||||
&mut picked,
|
||||
egui::color_picker::Alpha::OnlyBlend,
|
||||
);
|
||||
// Only on a change: a round trip through premultiplied alpha can nudge
|
||||
// an untouched colour by a step.
|
||||
if response.changed() {
|
||||
*colour = Colour::from(picked.to_srgba_unmultiplied());
|
||||
}
|
||||
}
|
||||
|
||||
/// The font: the installed families to pick from, and a box to type any name
|
||||
/// into — the only way on a system whose families the app cannot list.
|
||||
fn font_picker(ui: &mut egui::Ui, font: &mut Option<String>) {
|
||||
let filter_id = egui::Id::new("subtitle-font-filter");
|
||||
let mut filter: String = ui.data(|data| data.get_temp(filter_id)).unwrap_or_default();
|
||||
let families = theme::font_families(ui.ctx());
|
||||
egui::ComboBox::from_id_salt("subtitle-font")
|
||||
.selected_text(font.as_deref().unwrap_or("Default"))
|
||||
.width(200.0)
|
||||
.height(320.0)
|
||||
// Not on any click: the search box is clicked into.
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut filter)
|
||||
.hint_text("Search or type a font name")
|
||||
.desired_width(f32::INFINITY),
|
||||
)
|
||||
.request_focus();
|
||||
let typed = filter.trim();
|
||||
if ui.selectable_label(font.is_none(), "Default").clicked() {
|
||||
*font = None;
|
||||
ui.close();
|
||||
}
|
||||
let wanted = typed.to_lowercase();
|
||||
let mut exact = false;
|
||||
for family in families.unwrap_or_default() {
|
||||
if !family.to_lowercase().contains(&wanted) {
|
||||
continue;
|
||||
}
|
||||
exact |= family.eq_ignore_ascii_case(typed);
|
||||
let label = egui::RichText::new(family.as_str());
|
||||
if ui
|
||||
.selectable_label(font.as_deref() == Some(family.as_str()), label)
|
||||
.clicked()
|
||||
{
|
||||
*font = Some(family.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if families.is_none() {
|
||||
ui.label(ui::muted("Reading the installed fonts…"));
|
||||
}
|
||||
if !typed.is_empty() && !exact && ui.button(format!("Use “{typed}”")).clicked() {
|
||||
*font = Some(typed.to_owned());
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|data| data.insert_temp(filter_id, filter));
|
||||
}
|
||||
|
||||
/// A sample line, half on a bright sky and half on night, drawn in the
|
||||
/// chosen style — so whether it reads on both is visible before a film is.
|
||||
///
|
||||
/// An approximation. The font is the face fontconfig picks for the chosen one,
|
||||
/// as libass's is, but only on Linux; elsewhere, and while it loads, it is the
|
||||
/// app's own. A bold or italic the face lacks is made up, as libass does, and
|
||||
/// there is no blur. Proportions follow mpv's, where sizes are in pixels of a
|
||||
/// 38-pixel font.
|
||||
fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) {
|
||||
const HEIGHT: f32 = 132.0;
|
||||
/// mpv's `sub-font-size`, the unit every other size is relative to.
|
||||
const MPV_FONT: f32 = 38.0;
|
||||
let width = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, HEIGHT), egui::Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||
|
||||
// Sky above haze on the left, a night street on the right.
|
||||
let half = egui::Rect::from_min_max(rect.min, egui::pos2(rect.center().x, rect.max.y));
|
||||
let mut sky = egui::Mesh::default();
|
||||
sky.colored_vertex(half.left_top(), egui::Color32::from_rgb(0x8E, 0xC5, 0xF0));
|
||||
sky.colored_vertex(half.right_top(), egui::Color32::from_rgb(0xB8, 0xDC, 0xF8));
|
||||
sky.colored_vertex(
|
||||
half.right_bottom(),
|
||||
egui::Color32::from_rgb(0xFA, 0xF7, 0xEE),
|
||||
);
|
||||
sky.colored_vertex(
|
||||
half.left_bottom(),
|
||||
egui::Color32::from_rgb(0xF2, 0xF0, 0xE8),
|
||||
);
|
||||
sky.add_triangle(0, 1, 2);
|
||||
sky.add_triangle(0, 2, 3);
|
||||
painter.rect_filled(rect, radius, egui::Color32::from_rgb(0x14, 0x16, 0x22));
|
||||
painter.add(sky);
|
||||
|
||||
let font_px = 22.0 * style.scale as f32;
|
||||
let unit = font_px / MPV_FONT;
|
||||
let face = theme::subtitle_face(
|
||||
ui.ctx(),
|
||||
style.font.as_deref().unwrap_or(SubtitleStyle::DEFAULT_FONT),
|
||||
style.bold,
|
||||
style.italic,
|
||||
);
|
||||
let (family, made_up_bold, made_up_italic) = match face {
|
||||
Some(face) => (
|
||||
theme::subtitle_family(),
|
||||
style.bold && !face.bold,
|
||||
style.italic && !face.italic,
|
||||
),
|
||||
None if style.bold => (theme::semibold(), false, style.italic),
|
||||
None => (egui::FontFamily::Proportional, false, style.italic),
|
||||
};
|
||||
let job = egui::text::LayoutJob::single_section(
|
||||
"Can you read this on both sides?".to_owned(),
|
||||
egui::TextFormat {
|
||||
font_id: egui::FontId::new(font_px, family),
|
||||
color: egui::Color32::PLACEHOLDER,
|
||||
italics: made_up_italic,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let galley = painter.layout_job(job);
|
||||
let size = galley.size();
|
||||
// Bottom of the line at `position` percent down, as mpv places it, kept
|
||||
// inside the strip.
|
||||
let margin = 8.0;
|
||||
let bottom = rect.top() + HEIGHT * style.position as f32 / 100.0 - margin;
|
||||
let top = bottom
|
||||
.min(rect.bottom() - margin)
|
||||
.max(rect.top() + margin + size.y)
|
||||
- size.y;
|
||||
let at = egui::pos2(rect.center().x - size.x / 2.0, top);
|
||||
let colour = |colour: Colour| {
|
||||
let [r, g, b, a] = colour.to_array();
|
||||
egui::Color32::from_rgba_unmultiplied(r, g, b, a)
|
||||
};
|
||||
|
||||
let shadow = style.shadow_offset as f32 * unit;
|
||||
match style.backdrop {
|
||||
SubtitleBackdrop::Box => {
|
||||
let padding = 6.0 * unit;
|
||||
let line = egui::Rect::from_min_size(at, size).expand(padding);
|
||||
if shadow > 0.0 {
|
||||
painter.rect_filled(
|
||||
line.translate(egui::vec2(shadow, shadow)),
|
||||
0.0,
|
||||
colour(style.shadow_colour),
|
||||
);
|
||||
}
|
||||
painter.rect_filled(line, 0.0, colour(style.box_colour));
|
||||
}
|
||||
SubtitleBackdrop::Outline => {
|
||||
let outline = style.outline_size as f32 * unit;
|
||||
let rings = |offset: egui::Vec2, ink: egui::Color32| {
|
||||
if outline <= 0.0 {
|
||||
painter.galley_with_override_text_color(at + offset, galley.clone(), ink);
|
||||
return;
|
||||
}
|
||||
for step in 0..8 {
|
||||
let angle = step as f32 * std::f32::consts::TAU / 8.0;
|
||||
let ring = egui::vec2(angle.cos(), angle.sin()) * outline;
|
||||
painter.galley_with_override_text_color(
|
||||
at + offset + ring,
|
||||
galley.clone(),
|
||||
ink,
|
||||
);
|
||||
}
|
||||
};
|
||||
if shadow > 0.0 {
|
||||
rings(egui::vec2(shadow, shadow), colour(style.shadow_colour));
|
||||
}
|
||||
if outline > 0.0 {
|
||||
rings(egui::Vec2::ZERO, colour(style.outline_colour));
|
||||
}
|
||||
}
|
||||
}
|
||||
if made_up_bold {
|
||||
// libass thickens a face with no bold by a stroke of its own colour.
|
||||
let thicken = (font_px / 40.0).max(0.5);
|
||||
painter.galley_with_override_text_color(
|
||||
at + egui::vec2(thicken, 0.0),
|
||||
galley.clone(),
|
||||
colour(style.colour),
|
||||
);
|
||||
}
|
||||
painter.galley_with_override_text_color(at, galley, colour(style.colour));
|
||||
}
|
||||
|
||||
/// The streaming cache: how big it may get, and a way to empty it.
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Storage",
|
||||
@@ -405,8 +874,8 @@ fn metadata_form(
|
||||
without this every tile is a pair of initials. Turning it on sends the titles \
|
||||
in your library — not your files, and nothing about what you have watched — to \
|
||||
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
|
||||
the ids it matched — not the titles — then go to ani.zip for episode names and \
|
||||
wide artwork.",
|
||||
a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
|
||||
not the titles — go to ani.zip for episode names and wide artwork.",
|
||||
));
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
@@ -420,7 +889,7 @@ fn metadata_form(
|
||||
if settings.enabled {
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(ui::muted("Provider"));
|
||||
for provider in ProviderId::ALL {
|
||||
for provider in ProviderId::CHOOSABLE {
|
||||
if ui
|
||||
.radio(settings.provider == provider, provider.label())
|
||||
.on_hover_text(provider.description())
|
||||
@@ -432,28 +901,35 @@ fn metadata_form(
|
||||
}
|
||||
}
|
||||
|
||||
// TMDB's is the only key there is: the provider's with TMDB chosen,
|
||||
// and with AniList, the art of films TVDB has none for.
|
||||
ui.add_space(theme::space::L);
|
||||
if settings.provider.needs_api_key() {
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: settings.provider,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted("TMDB API key"));
|
||||
} else {
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
"TMDB API key, optional. With one, films get TMDB's backdrops and logos, \
|
||||
asked for by the film's TMDB id alone.",
|
||||
));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: ProviderId::Tmdb,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
));
|
||||
|
||||
ui.add_space(theme::space::L);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
//! below is the only place a link is ever visible, and only while it is being
|
||||
//! typed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pstr_core::Share;
|
||||
use pstr_core::catalog::CrawlRecord;
|
||||
use pstr_core::library::Library;
|
||||
|
||||
use crate::app::{Action, ShareForm};
|
||||
@@ -16,6 +19,7 @@ use crate::ui;
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
shares: &[Share],
|
||||
crawls: &HashMap<String, CrawlRecord>,
|
||||
library: &Library,
|
||||
form: &mut ShareForm,
|
||||
account: &mut ui::account::AccountPanel,
|
||||
@@ -36,7 +40,7 @@ pub fn show(
|
||||
.iter()
|
||||
.filter(|title| title.share_ids.contains(&share.id))
|
||||
.count();
|
||||
share_row(ui, share, titles, actions);
|
||||
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
|
||||
}
|
||||
|
||||
ui.add_space(theme::space::XXL);
|
||||
@@ -48,7 +52,14 @@ pub fn show(
|
||||
});
|
||||
}
|
||||
|
||||
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
||||
fn share_row(
|
||||
ui: &mut egui::Ui,
|
||||
share: &Share,
|
||||
titles: usize,
|
||||
crawl: Option<&CrawlRecord>,
|
||||
form: &mut ShareForm,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||
@@ -67,7 +78,23 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
||||
if share.has_custom_password {
|
||||
detail.push_str(" · password protected");
|
||||
}
|
||||
match crawl {
|
||||
Some(crawl) if crawl.crawled_at > 0 => {
|
||||
detail.push_str(" · crawled ");
|
||||
detail.push_str(&ui::ago(crawl.crawled_at, crate::engine::unix_now()));
|
||||
}
|
||||
_ => detail.push_str(" · not crawled yet"),
|
||||
}
|
||||
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||
// Why the last look failed, until one succeeds: a link
|
||||
// revoked upstream otherwise just stops growing.
|
||||
if let Some(error) = crawl.and_then(|crawl| crawl.error.as_deref()) {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(format!("Last crawl failed: {error}"))
|
||||
.color(theme::danger()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -81,12 +108,121 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
||||
if ui.button("Crawl").clicked() {
|
||||
actions.push(Action::Crawl(Some(share.id.clone())));
|
||||
}
|
||||
if ui.button("Rename").clicked() {
|
||||
form.renaming = Some(share.id.clone());
|
||||
form.rename_to = share.name.clone();
|
||||
}
|
||||
// Not for a folder of the account, which has no link.
|
||||
if share.folder.is_none()
|
||||
&& ui
|
||||
.button("Re-enter link")
|
||||
.on_hover_text(
|
||||
"For a keyring that lost the link, or a password that changed. \
|
||||
The library, history and downloads stay.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
form.repairing = Some(share.id.clone());
|
||||
form.repair_url.clear();
|
||||
form.repair_password.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
if form.repairing.as_deref() == Some(share.id.as_str()) {
|
||||
repair_form(ui, share, form, actions);
|
||||
}
|
||||
if form.renaming.as_deref() == Some(share.id.as_str()) {
|
||||
rename_form(ui, share, form, actions);
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
|
||||
/// A new name for a share, in its own row. Enter saves, as everywhere else.
|
||||
fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.horizontal(|ui| {
|
||||
let field = ui.add(
|
||||
egui::TextEdit::singleline(&mut form.rename_to)
|
||||
.hint_text("Name")
|
||||
.desired_width(320.0),
|
||||
);
|
||||
let ready = !form.rename_to.trim().is_empty();
|
||||
let entered = field.lost_focus() && ui_enter_pressed(ui.ctx());
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||
.inner
|
||||
.clicked()
|
||||
|| (ready && entered)
|
||||
{
|
||||
actions.push(Action::RenameShare {
|
||||
id: share.id.clone(),
|
||||
name: form.rename_to.trim().to_owned(),
|
||||
});
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
form.renaming = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The same link again, and a password if it has one, for a share already in
|
||||
/// the library.
|
||||
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.repair_url)
|
||||
.password(true)
|
||||
.hint_text("The share link, including everything after the #")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.repair_password)
|
||||
.password(true)
|
||||
.hint_text("Link password, if it has one")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
let problem = (!form.repair_url.trim().is_empty())
|
||||
.then(|| pstr_core::shares::share_token(form.repair_url.trim()).err())
|
||||
.flatten();
|
||||
if let Some(problem) = &problem {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(problem.to_string())
|
||||
.color(theme::danger()),
|
||||
);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let ready = !form.repair_url.trim().is_empty() && problem.is_none();
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RepairShare {
|
||||
id: share.id.clone(),
|
||||
url: form.repair_url.trim().to_owned(),
|
||||
password: Some(form.repair_password.clone())
|
||||
.filter(|password| !password.is_empty()),
|
||||
});
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
form.repairing = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The add form's link field, which the library's empty state focuses.
|
||||
pub fn link_field_id() -> egui::Id {
|
||||
egui::Id::new("share-link")
|
||||
}
|
||||
|
||||
fn ui_enter_pressed(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|input| input.key_pressed(egui::Key::Enter))
|
||||
}
|
||||
|
||||
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Add a share");
|
||||
ui.label(ui::muted(
|
||||
@@ -102,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted("Name"));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.name)
|
||||
.hint_text("Anime")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::M);
|
||||
// Enter in any field of the form submits it, as the sign-in
|
||||
// form's do.
|
||||
let mut entered = false;
|
||||
let mut submitted_from = |response: egui::Response| {
|
||||
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
|
||||
};
|
||||
|
||||
ui.label(ui::muted("Link"));
|
||||
ui.horizontal(|ui| {
|
||||
let paste = 60.0;
|
||||
let field = egui::Id::new("share-link");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(ui.available_width() - paste),
|
||||
let field = link_field_id();
|
||||
if std::mem::take(&mut form.focus) {
|
||||
ui.memory_mut(|memory| memory.request_focus(field));
|
||||
}
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(ui.available_width() - paste),
|
||||
),
|
||||
);
|
||||
// The link is masked, so pasting is the only sensible way in —
|
||||
// and a button says that more plainly than the field does.
|
||||
@@ -144,20 +284,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
}
|
||||
ui.add_space(theme::space::M);
|
||||
|
||||
ui.label(ui::muted("Name (optional)"));
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.name)
|
||||
.hint_text("The shared folder's own name")
|
||||
.desired_width(f32::INFINITY),
|
||||
),
|
||||
);
|
||||
ui.add_space(theme::space::M);
|
||||
|
||||
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||
if form.has_password {
|
||||
ui.add_space(theme::space::S);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.password)
|
||||
.password(true)
|
||||
.hint_text("Link password")
|
||||
.desired_width(f32::INFINITY),
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.password)
|
||||
.password(true)
|
||||
.hint_text("Link password")
|
||||
.desired_width(f32::INFINITY),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(theme::space::L);
|
||||
let ready = !form.name.trim().is_empty()
|
||||
&& !form.url.trim().is_empty()
|
||||
let ready = !form.url.trim().is_empty()
|
||||
&& problem.is_none()
|
||||
&& (!form.has_password || !form.password.is_empty())
|
||||
&& !form.sending;
|
||||
@@ -167,11 +318,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
||||
let clicked = ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
.clicked();
|
||||
if clicked || (ready && entered) {
|
||||
actions.push(Action::AddShare {
|
||||
name: form.name.trim().to_string(),
|
||||
url: form.url.trim().to_string(),
|
||||
@@ -184,8 +335,8 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
if form.sending {
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
ui.label(ui::muted("adding…"));
|
||||
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
||||
ui.label(ui::muted("A name and a link are needed."));
|
||||
} else if form.url.trim().is_empty() {
|
||||
ui.label(ui::muted("Paste a share link to add it."));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,9 +36,13 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
||||
("N / P", "Next and previous episode"),
|
||||
("[ / ]", "Slower and faster"),
|
||||
("S", "Skip the opening or the credits"),
|
||||
("Z / X", "Subtitles 0.1 s earlier and later"),
|
||||
("E", "The list of episodes"),
|
||||
("F or double-click", "Fullscreen"),
|
||||
("Esc", "Leave fullscreen, then the player"),
|
||||
(
|
||||
"Esc",
|
||||
"Close a panel, leave fullscreen, then leave the player",
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||
use crate::playback::PlaybackTarget;
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, Art, Card};
|
||||
|
||||
pub struct OfflineView<'a> {
|
||||
@@ -15,15 +16,29 @@ pub struct OfflineView<'a> {
|
||||
pub files: &'a std::collections::HashSet<DownloadKey>,
|
||||
}
|
||||
|
||||
/// Where the title stands beyond its own files.
|
||||
pub struct Context<'a> {
|
||||
/// The keys of its whole franchise in release order, if it has one.
|
||||
pub franchise: Option<&'a [String]>,
|
||||
/// The keys of the titles most like it, best first.
|
||||
pub similar: &'a [String],
|
||||
pub on_list: bool,
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
library: &Library,
|
||||
key: &str,
|
||||
franchise: Option<&[String]>,
|
||||
context: Context<'_>,
|
||||
offline: OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let Context {
|
||||
franchise,
|
||||
similar,
|
||||
on_list,
|
||||
} = context;
|
||||
let Some(title) = library.get(key) else {
|
||||
// The catalog was replaced under the page — a recrawl that dropped this
|
||||
// title. Nothing to show, so go back rather than draw an empty shell.
|
||||
@@ -31,7 +46,7 @@ pub fn show(
|
||||
return;
|
||||
};
|
||||
|
||||
if back_link(ui).clicked() {
|
||||
if back_link(ui, "Library").clicked() {
|
||||
actions.push(Action::Goto(Page::Library));
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
@@ -39,14 +54,21 @@ pub fn show(
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
header(ui, art, title, &offline, actions);
|
||||
header(ui, art, title, on_list, &offline, actions);
|
||||
ui.add_space(theme::space::XL);
|
||||
|
||||
if let Some(keys) = franchise {
|
||||
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
|
||||
if members.len() > 1 {
|
||||
ui::section(ui, "In this franchise");
|
||||
franchise_row(ui, art, title, &members, actions);
|
||||
cover_row(
|
||||
ui,
|
||||
art,
|
||||
("franchise", &title.key),
|
||||
Some(title),
|
||||
&members,
|
||||
actions,
|
||||
);
|
||||
ui.add_space(theme::space::XL);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +106,27 @@ pub fn show(
|
||||
.enumerate()
|
||||
.map(|(index, label)| (index, label.as_str()))
|
||||
.collect();
|
||||
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
|
||||
// A long-running show's row of pills ran off the page;
|
||||
// past a handful it is a list to pick from instead.
|
||||
let chosen = if choices.len() > SEASON_PILLS {
|
||||
egui::ComboBox::from_id_salt(("season-list", &title.key))
|
||||
.selected_text(labels[picked].as_str())
|
||||
.height(360.0)
|
||||
.show_ui(ui, |ui| {
|
||||
let mut chosen = None;
|
||||
for &(index, label) in &choices {
|
||||
if ui.selectable_label(index == picked, label).clicked() {
|
||||
chosen = Some(index);
|
||||
}
|
||||
}
|
||||
chosen
|
||||
})
|
||||
.inner
|
||||
.flatten()
|
||||
} else {
|
||||
ui::widgets::segmented(ui, picked, &choices)
|
||||
};
|
||||
if let Some(index) = chosen {
|
||||
picked = index;
|
||||
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||
}
|
||||
@@ -96,6 +138,7 @@ pub fn show(
|
||||
)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
season_download(ui, title, season, &offline, actions);
|
||||
season_watched(ui, title, season, actions);
|
||||
});
|
||||
});
|
||||
ui.add_space(theme::space::L);
|
||||
@@ -107,61 +150,173 @@ pub fn show(
|
||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||
}
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
let cast = art
|
||||
.metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.map(|details| details.cast.clone())
|
||||
.unwrap_or_default();
|
||||
if !cast.is_empty() {
|
||||
ui.add_space(theme::space::L);
|
||||
ui::section(ui, "Cast");
|
||||
cast_row(ui, art, &title.key, &cast, actions);
|
||||
ui.add_space(theme::space::L);
|
||||
}
|
||||
|
||||
// Under the episodes rather than over them: it is where to go
|
||||
// once this one is done, not what the page is for.
|
||||
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
|
||||
if !similar.is_empty() {
|
||||
ui.add_space(theme::space::L);
|
||||
ui::section(ui, "More like this");
|
||||
cover_row(ui, art, ("similar", &title.key), None, &similar, actions);
|
||||
ui.add_space(theme::space::L);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Every title of the franchise, in release order, as covers — this one
|
||||
/// marked rather than left out, so the row says where it sits in the story.
|
||||
fn franchise_row(
|
||||
/// The characters, each a round portrait over their name and who voices
|
||||
/// them. A voice actor opens every title in the library they are in.
|
||||
fn cast_row(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
current: &Title,
|
||||
key: &str,
|
||||
cast: &[pstr_core::metadata::CastMember],
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
const SIZE: f32 = 88.0;
|
||||
ui::shelf(ui, ("cast", key), |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::space::L;
|
||||
for (i, member) in cast.iter().enumerate() {
|
||||
let tile = ui.allocate_ui(egui::vec2(SIZE + 16.0, SIZE + 52.0), |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
let (rect, _) =
|
||||
ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover());
|
||||
let picture = member
|
||||
.character_image
|
||||
.as_deref()
|
||||
.or(member.actor_image.as_deref())
|
||||
.and_then(|url| art.picture(url));
|
||||
let painter = ui.painter();
|
||||
match picture {
|
||||
Some(texture) => {
|
||||
let shape = egui::epaint::RectShape::filled(
|
||||
rect,
|
||||
egui::CornerRadius::same((SIZE / 2.0) as u8),
|
||||
egui::Color32::WHITE,
|
||||
)
|
||||
.with_texture(
|
||||
texture.id(),
|
||||
super::cover_uv(texture.size_vec2(), rect.size()),
|
||||
);
|
||||
painter.add(shape);
|
||||
}
|
||||
None => {
|
||||
painter.circle_filled(rect.center(), SIZE / 2.0, theme::card());
|
||||
}
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
theme::Role::Caption
|
||||
.rich(&member.character)
|
||||
.color(theme::text()),
|
||||
)
|
||||
.truncate()
|
||||
.selectable(false),
|
||||
);
|
||||
if let Some(actor) = &member.actor {
|
||||
ui.add(
|
||||
egui::Label::new(ui::muted(actor))
|
||||
.truncate()
|
||||
.selectable(false),
|
||||
);
|
||||
}
|
||||
rect
|
||||
})
|
||||
.inner
|
||||
});
|
||||
// The whole tile opens the actor's page, names included, not only the portrait.
|
||||
let Some(actor) = &member.actor else { continue };
|
||||
let response = ui
|
||||
.interact(
|
||||
tile.response.rect,
|
||||
ui.id().with(("cast", i)),
|
||||
egui::Sense::click(),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!("Everything in the library with {actor}"));
|
||||
if response.hovered() {
|
||||
ui.painter().circle_stroke(
|
||||
tile.inner.center(),
|
||||
SIZE / 2.0,
|
||||
egui::Stroke::new(2.0, theme::accent()),
|
||||
);
|
||||
}
|
||||
if response.clicked() {
|
||||
actions.push(Action::Goto(Page::Browse {
|
||||
facet: pstr_core::discover::Facet::Person(actor.clone()),
|
||||
from: Some(key.to_owned()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A row of covers: every title of the franchise in release order, with
|
||||
/// `current` marked rather than left out, so the row says where it sits in
|
||||
/// the story — or, with no `current`, the titles like this one.
|
||||
fn cover_row(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
current: Option<&Title>,
|
||||
members: &[&Title],
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt(("franchise", ¤t.key))
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for member in members {
|
||||
let here = member.key == current.key;
|
||||
let picture = art.poster(member);
|
||||
let found = art
|
||||
.metadata
|
||||
.get(&member.key)
|
||||
.and_then(|record| record.metadata.as_ref());
|
||||
let what = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.format_label())
|
||||
.unwrap_or(match member.kind {
|
||||
TitleKind::Series => "Series",
|
||||
TitleKind::Film => "Film",
|
||||
});
|
||||
let year = found
|
||||
.and_then(|found| found.year)
|
||||
.or(member.year)
|
||||
.map(|year| format!(" · {year}"))
|
||||
.unwrap_or_default();
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(member),
|
||||
subtitle: format!("{what}{year}"),
|
||||
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: here.then(|| "Here".to_owned()),
|
||||
width: 128.0,
|
||||
aspect: theme::POSTER_ASPECT,
|
||||
},
|
||||
);
|
||||
if !here && response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(member.key.clone())));
|
||||
}
|
||||
ui::shelf(ui, id, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for member in members {
|
||||
let here = current.is_some_and(|current| member.key == current.key);
|
||||
let picture = art.poster(member);
|
||||
let found = art
|
||||
.metadata
|
||||
.get(&member.key)
|
||||
.and_then(|record| record.metadata.as_ref());
|
||||
let what = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.format_label())
|
||||
.unwrap_or(match member.kind {
|
||||
TitleKind::Series => "Series",
|
||||
TitleKind::Film => "Film",
|
||||
});
|
||||
let year = found
|
||||
.and_then(|found| found.year)
|
||||
.or(member.year)
|
||||
.map(|year| format!(" · {year}"))
|
||||
.unwrap_or_default();
|
||||
let response = ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: picture,
|
||||
name: art.name(member),
|
||||
subtitle: format!("{what}{year}"),
|
||||
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: here.then(|| "Here".to_owned()),
|
||||
width: 128.0,
|
||||
aspect: theme::POSTER_ASPECT,
|
||||
},
|
||||
);
|
||||
if !here && response.clicked() {
|
||||
actions.push(Action::Goto(Page::Title(member.key.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A title's poster, at the poster's own shape, with how far into it the
|
||||
@@ -190,9 +345,10 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
|
||||
}
|
||||
|
||||
/// "← Library", as a link rather than a button: it is navigation, and a
|
||||
/// filled button beside the page's real actions competed with them.
|
||||
fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
|
||||
/// filled button beside the page's real actions competed with them. `to`
|
||||
/// names where it goes.
|
||||
pub(crate) fn back_link(ui: &mut egui::Ui, to: &str) -> egui::Response {
|
||||
let text = theme::Role::Label.rich(format!("{} {to}", egui_phosphor::regular::ARROW_LEFT));
|
||||
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
||||
if response.hovered() {
|
||||
ui.painter().hline(
|
||||
@@ -207,6 +363,29 @@ fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||
}
|
||||
|
||||
/// Download, or delete, a whole season.
|
||||
/// Mark a whole season seen, or unseen once it all is — for someone who
|
||||
/// watched it elsewhere and joins the show here.
|
||||
fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &mut Vec<Action>) {
|
||||
let all = season.episodes.iter().all(|episode| episode.is_watched());
|
||||
let label = if all {
|
||||
"Mark season unwatched"
|
||||
} else {
|
||||
"Mark season watched"
|
||||
};
|
||||
if ui::tonal_button(ui, label).clicked() {
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links: season
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||
.collect(),
|
||||
watched: !all,
|
||||
what: format!("{} {}", title.name, season.label()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn season_download(
|
||||
ui: &mut egui::Ui,
|
||||
title: &Title,
|
||||
@@ -222,9 +401,10 @@ fn season_download(
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in season_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
actions.push(Action::DeleteOffline(
|
||||
season_keys,
|
||||
format!("{} {}", title.name, season.label()),
|
||||
));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download season")
|
||||
@@ -246,6 +426,7 @@ fn header(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
title: &Title,
|
||||
on_list: bool,
|
||||
offline: &OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -297,7 +478,9 @@ fn header(
|
||||
// Text in a column a person can read, with the picture to the
|
||||
// right of it rather than under it.
|
||||
ui.set_max_width(ui.available_width().min(820.0));
|
||||
ui.label(theme::Role::Display.rich(&shown).strong());
|
||||
if ui::logo_heading(ui, art, title, 120.0).is_none() {
|
||||
ui.label(theme::Role::Hero.rich(&shown).color(theme::text()));
|
||||
}
|
||||
// Whichever of the share's name and the provider's is not the one
|
||||
// shown. Worth showing rather than only replacing: a viewer should
|
||||
// be able to tell what the match actually matched, and find the
|
||||
@@ -320,6 +503,30 @@ fn header(
|
||||
}
|
||||
ui.add_space(theme::space::XS);
|
||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||
// What the files are, rather than what the title is: set apart
|
||||
// from the line above as small outlined marks, the way a box
|
||||
// prints them.
|
||||
//
|
||||
// After where the title stands, which is the one fact here that
|
||||
// changes week to week.
|
||||
let quality = title.quality().badges();
|
||||
let status = found
|
||||
.as_ref()
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| status_text(details, now()));
|
||||
if !quality.is_empty() || status.is_some() {
|
||||
ui.add_space(theme::space::XS);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||
if let Some((text, live)) = &status {
|
||||
ui::widgets::status_chip(ui, text, *live);
|
||||
ui.add_space(theme::space::XS);
|
||||
}
|
||||
for badge in quality {
|
||||
ui::widgets::spec_badge(ui, badge);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(theme::space::L);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
@@ -346,8 +553,7 @@ fn header(
|
||||
}
|
||||
// Beside the button it is the alternative to.
|
||||
if resuming
|
||||
&& ui
|
||||
.button("Start over")
|
||||
&& ui::tonal_button(ui, "Start over")
|
||||
.on_hover_text("Play from the beginning")
|
||||
.clicked()
|
||||
{
|
||||
@@ -357,20 +563,17 @@ fn header(
|
||||
None,
|
||||
)));
|
||||
}
|
||||
ui::list_button(ui, &title.key, on_list, actions);
|
||||
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||
if all_offline {
|
||||
if ui
|
||||
.button("Delete show")
|
||||
if ui::tonal_button(ui, "Delete show")
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in title_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download show")
|
||||
} else if ui::tonal_button(ui, "Download show")
|
||||
.on_hover_text("Download every episode for disconnected playback")
|
||||
.clicked()
|
||||
{
|
||||
@@ -405,13 +608,15 @@ fn header(
|
||||
// wrongly. Offered whether or not anything matched: the two
|
||||
// cases it exists for are a title with no poster at all and a
|
||||
// title wearing someone else's.
|
||||
if ui
|
||||
.button(match &found {
|
||||
if ui::tonal_button(
|
||||
ui,
|
||||
match &found {
|
||||
Some(_) => "Change match",
|
||||
None => "Match…",
|
||||
})
|
||||
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
||||
.clicked()
|
||||
},
|
||||
)
|
||||
.on_hover_text("Search the metadata provider and pick the entry yourself")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenMatcher(title.key.clone()));
|
||||
}
|
||||
@@ -419,14 +624,14 @@ fn header(
|
||||
|
||||
if let Some(found) = &found {
|
||||
ui.add_space(theme::space::L);
|
||||
description(ui, found);
|
||||
description(ui, found, &title.key, actions);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some((texture, index)) = backdrop {
|
||||
ui.add_space(theme::space::L);
|
||||
band(ui, &texture, index);
|
||||
band(ui, &texture, index, art.mood(title));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,10 +645,15 @@ fn header(
|
||||
/// page colour so text laid over it stays readable whatever the still happens to
|
||||
/// be, and a fade into the page along the bottom so the band ends rather than
|
||||
/// stops.
|
||||
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
|
||||
fn band(
|
||||
ui: &mut egui::Ui,
|
||||
texture: &egui::TextureHandle,
|
||||
index: egui::layers::ShapeIdx,
|
||||
mood: Option<egui::Color32>,
|
||||
) {
|
||||
// Full width of the window, not of the content: the panel's own margin is
|
||||
// what the band has to reach past, or it reads as a picture in a box.
|
||||
const BLEED: f32 = 18.0;
|
||||
const BLEED: f32 = super::PAGE_MARGIN.x;
|
||||
let content = ui.min_rect();
|
||||
let rect = egui::Rect::from_min_max(
|
||||
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
|
||||
@@ -477,7 +687,12 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
||||
//
|
||||
// Heavier still when there is no fade coming, since then these are the only
|
||||
// things standing between the text and the picture.
|
||||
//
|
||||
// Both in the page colour tinted by the picture's own, so each title's
|
||||
// page carries its light; the fade at the bottom stays the page's, since
|
||||
// that is what it ends in.
|
||||
let ramps = theme::ramps_on();
|
||||
let veil = theme::mood_veil(mood);
|
||||
layers.push(
|
||||
egui::epaint::RectShape::filled(
|
||||
rect,
|
||||
@@ -485,7 +700,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
||||
// Lighter with the ramps on: the horizontal one below is solid
|
||||
// where the text is, so this coat only has to calm the picture's
|
||||
// right side, not make it disappear.
|
||||
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
|
||||
veil.gamma_multiply(if ramps { 0.72 } else { 0.94 }),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
@@ -497,7 +712,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
||||
layers.push(theme::fade_shape(
|
||||
ui.ctx(),
|
||||
text_side,
|
||||
theme::background(),
|
||||
veil,
|
||||
theme::Direction::Horizontal,
|
||||
// Solid at the left, where the poster and the text are.
|
||||
false,
|
||||
@@ -524,17 +739,31 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
||||
}
|
||||
|
||||
/// What the provider had to say. Only ever drawn under a match.
|
||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||
let tags = found
|
||||
.details
|
||||
.as_ref()
|
||||
///
|
||||
/// Its genres, studios and director are chips that open every title in the
|
||||
/// library sharing them.
|
||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mut Vec<Action>) {
|
||||
use pstr_core::discover::Facet;
|
||||
let browse = |facet: Facet| {
|
||||
Action::Goto(Page::Browse {
|
||||
facet,
|
||||
from: Some(key.to_owned()),
|
||||
})
|
||||
};
|
||||
let details = found.details.as_ref();
|
||||
let tags = details
|
||||
.map(|details| details.tags.as_slice())
|
||||
.unwrap_or_default();
|
||||
if !found.genres.is_empty() || !tags.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
for genre in &found.genres {
|
||||
ui::widgets::chip(ui, genre);
|
||||
if ui::widgets::link_chip(ui, genre)
|
||||
.on_hover_text(format!("Every {genre} title"))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(browse(Facet::Genre(genre.clone())));
|
||||
}
|
||||
}
|
||||
// After the genres and quieter than them: a tag is the
|
||||
// provider's community describing it, not what it is filed as.
|
||||
@@ -544,6 +773,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||
});
|
||||
ui.add_space(theme::space::M);
|
||||
}
|
||||
// Who made it, out of the grey line and onto chips, so the names lead
|
||||
// somewhere.
|
||||
let studios = details
|
||||
.map(|details| &details.studios[..details.studios.len().min(2)])
|
||||
.unwrap_or_default();
|
||||
let directors = details
|
||||
.map(|details| &details.directors[..details.directors.len().min(1)])
|
||||
.unwrap_or_default();
|
||||
if !studios.is_empty() || !directors.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
for (label, names, facet) in [
|
||||
("Studio", studios, Facet::Studio as fn(String) -> Facet),
|
||||
("Director", directors, Facet::Person),
|
||||
] {
|
||||
if names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
ui.label(ui::muted(label));
|
||||
for name in names {
|
||||
if ui::widgets::link_chip(ui, name)
|
||||
.on_hover_text(format!("Everything in the library by {name}"))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(browse(facet(name.clone())));
|
||||
}
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::space::M);
|
||||
}
|
||||
if let Some(overview) = &found.overview {
|
||||
// Four lines, then "More". A provider's synopsis runs to three
|
||||
// paragraphs on a long show, and all of them pushed the episodes —
|
||||
@@ -581,12 +842,28 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(url) = &found.url {
|
||||
let trailer = details
|
||||
.and_then(|details| details.trailer.as_ref())
|
||||
.and_then(|trailer| trailer.url());
|
||||
if found.url.is_some() || trailer.is_some() {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.hyperlink_to(
|
||||
ui::muted(format!("More on {}", found.provider.label())),
|
||||
url,
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
// In the browser: the app itself never requests the trailer.
|
||||
if let Some(trailer) = &trailer {
|
||||
ui.hyperlink_to(
|
||||
ui::muted(format!("{} Trailer", egui_phosphor::regular::FILM_STRIP)),
|
||||
trailer,
|
||||
)
|
||||
.on_hover_text("Opens in your browser");
|
||||
ui.add_space(theme::space::L);
|
||||
}
|
||||
if let Some(url) = &found.url {
|
||||
ui.hyperlink_to(
|
||||
ui::muted(format!("More on {}", found.provider.label())),
|
||||
url,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +882,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
||||
if let Some(year) = title.year {
|
||||
parts.push(year.to_string());
|
||||
}
|
||||
// A film's running time is what it costs to start it. A series' is its
|
||||
// episodes', which each row says.
|
||||
if title.kind == TitleKind::Film
|
||||
&& let Some(minutes) = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.runtime)
|
||||
{
|
||||
parts.push(ui::format_runtime(minutes));
|
||||
}
|
||||
if title.kind == TitleKind::Series {
|
||||
if title.seasons.len() > 1 {
|
||||
parts.push(ui::library::plural(title.seasons.len(), "season"));
|
||||
@@ -616,29 +902,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
||||
parts.push(format!("{watched} watched"));
|
||||
}
|
||||
|
||||
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
|
||||
if let Some(season) = details.season_label() {
|
||||
parts.push(season);
|
||||
}
|
||||
parts.extend(details.studios.iter().take(2).cloned());
|
||||
if let Some(director) = details.directors.first() {
|
||||
parts.push(format!("dir. {director}"));
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_secs() as i64);
|
||||
if let Some((episode, at)) = details.next_airing(now) {
|
||||
let when = chrono::DateTime::from_timestamp(at, 0)
|
||||
.map(|at| {
|
||||
at.with_timezone(&chrono::Local)
|
||||
.format("%a %-d %b, %H:%M")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
parts.push(format!("episode {episode} airs {when}"));
|
||||
} else if details.is_airing() {
|
||||
parts.push("airing".to_owned());
|
||||
}
|
||||
if let Some(season) = found
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.season_label())
|
||||
{
|
||||
parts.push(season);
|
||||
}
|
||||
if let Some(found) = found {
|
||||
if let Some(rating) = found.rating {
|
||||
@@ -657,6 +925,37 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
/// The most seasons picked from a row of pills; past it, a list.
|
||||
const SEASON_PILLS: usize = 5;
|
||||
|
||||
/// Unix seconds, now.
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// What the status chip says — `Airing · episode 8 Sat 1 Nov, 14:00`,
|
||||
/// `Finished` — and whether more is coming.
|
||||
fn status_text(details: &pstr_core::metadata::TitleDetails, now: i64) -> Option<(String, bool)> {
|
||||
let next = details.next_airing(now);
|
||||
let label = details.status_label().or(next.map(|_| "Airing"))?;
|
||||
let live = next.is_some() || details.is_airing();
|
||||
Some(match next {
|
||||
Some((episode, at)) => {
|
||||
let when = chrono::DateTime::from_timestamp(at, 0)
|
||||
.map(|at| {
|
||||
at.with_timezone(&chrono::Local)
|
||||
.format("%a %-d %b, %H:%M")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(format!("{label} · episode {episode} {when}"), live)
|
||||
}
|
||||
None => (label.to_owned(), live),
|
||||
})
|
||||
}
|
||||
|
||||
/// How big an episode's still is drawn.
|
||||
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
||||
|
||||
@@ -677,6 +976,7 @@ fn episode_row(
|
||||
found.name.clone(),
|
||||
found.overview.clone(),
|
||||
found.air_date.clone(),
|
||||
found.runtime,
|
||||
)
|
||||
});
|
||||
let watched = episode.is_watched();
|
||||
@@ -708,14 +1008,17 @@ fn episode_row(
|
||||
.unwrap_or_default();
|
||||
let name = found
|
||||
.as_ref()
|
||||
.and_then(|(name, _, _)| name.clone())
|
||||
.and_then(|(name, ..)| name.clone())
|
||||
.unwrap_or_else(|| episode.detail().to_string());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
if !numbering.is_empty() {
|
||||
job.append(
|
||||
&format!("{numbering} "),
|
||||
0.0,
|
||||
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
|
||||
egui::TextFormat::simple(
|
||||
egui::FontId::new(theme::Role::Label.size(), theme::semibold()),
|
||||
theme::muted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
job.append(
|
||||
@@ -738,9 +1041,10 @@ fn episode_row(
|
||||
ui.label(ui::muted(details(
|
||||
episode,
|
||||
found.as_ref().and_then(|f| f.2.as_deref()),
|
||||
found.as_ref().and_then(|f| f.3),
|
||||
)));
|
||||
|
||||
if let Some((_, Some(overview), _)) = &found {
|
||||
if let Some((_, Some(overview), ..)) = &found {
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
overview.clone(),
|
||||
theme::Role::Caption.font(),
|
||||
@@ -760,14 +1064,33 @@ fn episode_row(
|
||||
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||
if ui::widgets::watched_mark(ui, watched)
|
||||
.on_hover_text(if watched {
|
||||
"Mark unwatched"
|
||||
} else {
|
||||
"Mark watched"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
|
||||
"Mark unwatched. Right-click for more."
|
||||
} else {
|
||||
"Mark watched. Right-click for more."
|
||||
});
|
||||
// Joining a show partway is the usual reason to mark
|
||||
// anything, and one tick per earlier episode is a chore.
|
||||
mark.context_menu(|ui| {
|
||||
if ui.button("Mark watched up to here").clicked() {
|
||||
let links = title
|
||||
.episodes()
|
||||
.take(
|
||||
title
|
||||
.position_of(&episode.node.share_id, &episode.node.link_id)
|
||||
.map_or(0, |at| at + 1),
|
||||
)
|
||||
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||
.collect();
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links,
|
||||
watched: true,
|
||||
what: format!("{} up to {}", title.name, episode.label()),
|
||||
});
|
||||
}
|
||||
});
|
||||
if mark.clicked() {
|
||||
actions.push(Action::SetWatched {
|
||||
share_id: episode.node.share_id.clone(),
|
||||
link_id: episode.node.link_id.clone(),
|
||||
@@ -793,9 +1116,9 @@ fn episode_row(
|
||||
ui.add_space(theme::space::XXS);
|
||||
}
|
||||
|
||||
/// The grey line under an episode's name: when it aired, how big it is, and
|
||||
/// where it would resume.
|
||||
fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
||||
/// The grey line under an episode's name: when it aired, how long it runs,
|
||||
/// how big it is, and where it would resume.
|
||||
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(at) = episode.resume_at() {
|
||||
let left = episode
|
||||
@@ -810,6 +1133,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
||||
if let Some(date) = air_date {
|
||||
parts.push(date.to_owned());
|
||||
}
|
||||
if let Some(minutes) = runtime {
|
||||
parts.push(ui::format_runtime(minutes));
|
||||
}
|
||||
match episode.node.size {
|
||||
// A file the share says is empty is one that will not play — an upload
|
||||
// that never finished, usually. Worth saying on the row rather than
|
||||
@@ -836,6 +1162,7 @@ pub(crate) fn still(
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||
let response = response.named(format!("Play {}", episode.label()));
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return;
|
||||
}
|
||||
@@ -844,12 +1171,20 @@ pub(crate) fn still(
|
||||
painter.rect_filled(rect, radius, theme::card_hover());
|
||||
// Asked for only once the row is on screen: a season of twenty-five is
|
||||
// twenty-five requests, and most of them are never scrolled to.
|
||||
if let Some(texture) = art.still(&title.key, episode) {
|
||||
let texture = art.still(&title.key, episode);
|
||||
// Faded in like a tile's art, so a season's stills settle rather than pop.
|
||||
let art_in = ui.ctx().animate_bool_with_time(
|
||||
response.id.with("art"),
|
||||
texture.is_some(),
|
||||
theme::motion::ARRIVE,
|
||||
);
|
||||
if let Some(texture) = texture {
|
||||
let tint = if watched {
|
||||
egui::Color32::from_gray(150)
|
||||
} else {
|
||||
egui::Color32::WHITE
|
||||
};
|
||||
}
|
||||
.gamma_multiply(art_in);
|
||||
painter.add(
|
||||
egui::epaint::RectShape::filled(rect, radius, tint)
|
||||
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||
@@ -910,6 +1245,21 @@ pub(crate) fn still(
|
||||
theme::accent_fill(&painter, played, radius, 0.0);
|
||||
}
|
||||
|
||||
// Seen, said on the picture as well as by the mark at the row's end: a
|
||||
// season is scanned by its stills, and a dimmed one alone read as one that
|
||||
// had not loaded.
|
||||
if watched {
|
||||
let corner = rect.right_top() + egui::vec2(-14.0, 14.0);
|
||||
painter.circle_filled(corner, 9.0, theme::accent());
|
||||
painter.text(
|
||||
corner,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
egui_phosphor::regular::CHECK,
|
||||
theme::Role::Micro.font(),
|
||||
theme::on_accent(),
|
||||
);
|
||||
}
|
||||
|
||||
if response
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(if episode.resume_at().is_some() {
|
||||
@@ -941,7 +1291,7 @@ fn download(
|
||||
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
actions.push(Action::DeleteOffline(vec![key], episode.label()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -993,3 +1343,36 @@ fn key_of(episode: &Episode) -> DownloadKey {
|
||||
link_id: episode.node.link_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn details(status: &str, next: Option<(u32, i64)>) -> pstr_core::metadata::TitleDetails {
|
||||
pstr_core::metadata::TitleDetails {
|
||||
status: Some(status.to_owned()),
|
||||
next_episode: next.map(|(episode, _)| episode),
|
||||
next_airing_at: next.map(|(_, at)| at),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_chip_says_when_the_next_episode_airs_and_only_while_it_is_ahead() {
|
||||
let (text, live) = status_text(&details("RELEASING", Some((8, 2_000))), 1_000).unwrap();
|
||||
assert!(text.starts_with("Airing · episode 8 "), "{text}");
|
||||
assert!(live);
|
||||
// Fetched a while ago: the date has passed, so it says nothing of it.
|
||||
let (text, live) = status_text(&details("RELEASING", Some((8, 500))), 1_000).unwrap();
|
||||
assert_eq!(text, "Airing");
|
||||
assert!(live);
|
||||
assert_eq!(
|
||||
status_text(&details("FINISHED", None), 1_000),
|
||||
Some(("Finished".to_owned(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
status_text(&pstr_core::metadata::TitleDetails::default(), 0),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// How long a message stays up.
|
||||
const SECONDS: f64 = 5.0;
|
||||
@@ -52,6 +53,17 @@ impl Toasts {
|
||||
self.push_inner(now, text.into(), false, Some((label, action)));
|
||||
}
|
||||
|
||||
/// An error with a button on it — "Retry".
|
||||
pub fn push_error_with(
|
||||
&mut self,
|
||||
now: f64,
|
||||
text: impl Into<String>,
|
||||
label: &'static str,
|
||||
action: Action,
|
||||
) {
|
||||
self.push_inner(now, text.into(), true, Some((label, action)));
|
||||
}
|
||||
|
||||
fn push_inner(
|
||||
&mut self,
|
||||
now: f64,
|
||||
@@ -203,7 +215,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
||||
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||
Stroke::new(1.5, ink),
|
||||
);
|
||||
if response.on_hover_text("Dismiss").clicked() {
|
||||
if response.hint("Dismiss").clicked() {
|
||||
pressed = Pressed::Close;
|
||||
}
|
||||
if let Some((label, _)) = &toast.action {
|
||||
|
||||
@@ -11,12 +11,15 @@
|
||||
//! and neither mutates: this module draws.
|
||||
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
|
||||
use pstr_core::prefs::SubtitleStyle;
|
||||
use pstr_player::{MAX_VOLUME, Track, TrackKind};
|
||||
|
||||
use crate::app::{Action, Adjacent, Page};
|
||||
use crate::playback::{Command, Playback};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::player::{PlayerPanel, SleepChoice};
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
||||
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
|
||||
@@ -40,6 +43,9 @@ const ROW_HEIGHT_FULL: f32 = 44.0;
|
||||
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
|
||||
const ROW_HEIGHT_MINI: f32 = 34.0;
|
||||
|
||||
/// The speeds on offer, and the steps `[` and `]` walk.
|
||||
pub const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
|
||||
|
||||
/// Whether there is a file before and after this one in the same title.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Neighbours {
|
||||
@@ -52,6 +58,8 @@ pub fn full(
|
||||
ui: &mut egui::Ui,
|
||||
playback: &Playback,
|
||||
neighbours: Neighbours,
|
||||
panel: Option<PlayerPanel>,
|
||||
sleep: Option<SleepChoice>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
seek_bar(ui, playback, actions, 6.0);
|
||||
@@ -97,13 +105,11 @@ pub fn full(
|
||||
}));
|
||||
}
|
||||
|
||||
// Right cluster: everything that is a choice rather than a move.
|
||||
// Right cluster: everything that is a choice rather than a move. Each
|
||||
// opens its panel down the right of the picture rather than a menu
|
||||
// under the pointer: a list of twelve chapters is read, not aimed at.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
track_menus(ui, playback, actions);
|
||||
ui.add_space(theme::space::M);
|
||||
speed_menu(ui, playback, actions);
|
||||
ui.add_space(theme::space::M);
|
||||
chapter_menu(ui, playback, actions);
|
||||
panel_buttons(ui, playback, panel, sleep, actions);
|
||||
ui.add_space(theme::space::M);
|
||||
volume(ui, playback, actions);
|
||||
});
|
||||
@@ -163,7 +169,7 @@ pub fn mini(
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
if response.on_hover_text("Show the picture again").clicked() {
|
||||
if response.hint("Show the picture again").clicked() {
|
||||
actions.push(Action::Goto(Page::Player));
|
||||
}
|
||||
}
|
||||
@@ -313,7 +319,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
|
||||
}
|
||||
|
||||
if response
|
||||
.on_hover_text(if playback.paused {
|
||||
.hint(if playback.paused {
|
||||
"Play (Space)"
|
||||
} else {
|
||||
"Pause (Space)"
|
||||
@@ -354,7 +360,7 @@ fn step_button(
|
||||
enabled,
|
||||
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
||||
);
|
||||
if response.on_hover_text(hover).clicked() {
|
||||
if response.hint(hover).clicked() {
|
||||
push(actions);
|
||||
}
|
||||
}
|
||||
@@ -436,7 +442,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
};
|
||||
if ui
|
||||
.button(theme::Role::Subhead.rich(icon))
|
||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
||||
.hint(format!("Mute (M) — {:.0}%", playback.volume))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleMute);
|
||||
@@ -469,6 +475,8 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
|
||||
}
|
||||
ui.separator();
|
||||
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
|
||||
ui.separator();
|
||||
subtitle_adjustments(ui, playback, actions);
|
||||
})
|
||||
.response
|
||||
.on_hover_text("Subtitle track");
|
||||
@@ -488,6 +496,49 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
|
||||
}
|
||||
}
|
||||
|
||||
/// What is worth changing mid-episode without a trip to Settings: the size and
|
||||
/// place of the line, and its timing for a release that is out of step.
|
||||
pub fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
let mut style = playback.subtitle_style.clone();
|
||||
egui::Grid::new("subtitle-adjustments")
|
||||
.num_columns(2)
|
||||
.spacing([theme::space::M, theme::space::S])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Size");
|
||||
ui::settings::percent_slider(
|
||||
ui,
|
||||
&mut style.scale,
|
||||
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Position");
|
||||
ui::settings::subtitle_position_slider(ui, &mut style.position);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Delay");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("−").on_hover_text("Earlier (Z)").clicked() {
|
||||
actions.push(Action::NudgeSubtitleDelay(-0.1));
|
||||
}
|
||||
ui.label(ui::format_delay(playback.subtitle_delay));
|
||||
if ui.button("+").on_hover_text("Later (X)").clicked() {
|
||||
actions.push(Action::NudgeSubtitleDelay(0.1));
|
||||
}
|
||||
if ui
|
||||
.add_enabled(playback.subtitle_delay != 0.0, egui::Button::new("Reset"))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ResetSubtitleDelay);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
if style != playback.subtitle_style {
|
||||
actions.push(Action::SetSubtitleStyle(style.sanitized()));
|
||||
}
|
||||
}
|
||||
|
||||
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
|
||||
for track in tracks {
|
||||
if ui.selectable_label(track.selected, track.label()).clicked() {
|
||||
@@ -500,53 +551,73 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
|
||||
}
|
||||
}
|
||||
|
||||
/// How fast it plays. Says so on the button only when it is not normal speed,
|
||||
/// because that is the one case worth noticing at a glance.
|
||||
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
let label = if (playback.speed - 1.0).abs() < 1e-6 {
|
||||
"Speed".to_owned()
|
||||
} else {
|
||||
format!("Speed · {}", ui::format_speed(playback.speed))
|
||||
/// The buttons that open the player's panels, right to left: tracks, speed,
|
||||
/// chapters, sleep. Each shows only when there is a choice to make, and says
|
||||
/// what is chosen when that is worth noticing at a glance.
|
||||
fn panel_buttons(
|
||||
ui: &mut egui::Ui,
|
||||
playback: &Playback,
|
||||
open: Option<PlayerPanel>,
|
||||
sleep: Option<SleepChoice>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
|
||||
let response = if open == Some(panel) {
|
||||
ui::accent_button(ui, &text)
|
||||
} else {
|
||||
ui::glass_button(ui, &text)
|
||||
};
|
||||
if response.on_hover_text(hint).clicked() {
|
||||
actions.push(Action::TogglePanel(panel));
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
};
|
||||
ui.menu_button(label, |ui| {
|
||||
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
|
||||
if ui
|
||||
.selectable_label(
|
||||
(playback.speed - speed).abs() < 1e-6,
|
||||
ui::format_speed(speed),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::SetSpeed(speed));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_hover_text("Playback speed ([ and ])");
|
||||
}
|
||||
|
||||
/// Jump to a chapter. Only drawn for a file that has more than one, which for
|
||||
/// most releases means anime and not much else.
|
||||
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
if playback.chapters.len() < 2 {
|
||||
return;
|
||||
let subtitles = playback.tracks_of(TrackKind::Subtitle).count();
|
||||
let audio = playback.tracks_of(TrackKind::Audio).count();
|
||||
if subtitles > 0 || audio > 1 {
|
||||
let shown = match playback.selected_track(TrackKind::Subtitle) {
|
||||
Some(track) => short(track),
|
||||
None if subtitles > 0 => "Off".to_owned(),
|
||||
None => playback
|
||||
.selected_track(TrackKind::Audio)
|
||||
.map(short)
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
button(
|
||||
ui,
|
||||
PlayerPanel::Tracks,
|
||||
format!("{} {shown}", egui_phosphor::regular::SUBTITLES),
|
||||
"Audio and subtitles",
|
||||
);
|
||||
}
|
||||
let current = playback.chapter().map(|chapter| chapter.index);
|
||||
ui.menu_button("Chapters", |ui| {
|
||||
for chapter in &playback.chapters {
|
||||
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
|
||||
if ui
|
||||
.selectable_label(current == Some(chapter.index), label)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Player(Command::SeekTo(chapter.start)));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_hover_text("Chapters");
|
||||
let speed = if (playback.speed - 1.0).abs() < 1e-6 {
|
||||
egui_phosphor::regular::GAUGE.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{} {}",
|
||||
egui_phosphor::regular::GAUGE,
|
||||
ui::format_speed(playback.speed)
|
||||
)
|
||||
};
|
||||
button(ui, PlayerPanel::Speed, speed, "Playback speed ([ and ])");
|
||||
// Only for a file with more than one, which for most releases means anime
|
||||
// and not much else.
|
||||
if playback.chapters.len() > 1 {
|
||||
button(
|
||||
ui,
|
||||
PlayerPanel::Chapters,
|
||||
egui_phosphor::regular::LIST_NUMBERS.to_owned(),
|
||||
"Chapters",
|
||||
);
|
||||
}
|
||||
let moon = egui_phosphor::regular::MOON;
|
||||
let sleep = match sleep {
|
||||
None => moon.to_owned(),
|
||||
Some(SleepChoice::EndOfEpisode) => format!("{moon} End"),
|
||||
Some(SleepChoice::Minutes(left)) => format!("{moon} {left} min"),
|
||||
};
|
||||
button(ui, PlayerPanel::Sleep, sleep, "Sleep timer");
|
||||
}
|
||||
|
||||
/// A track in as few words as fit on a button: what distinguishes it from the
|
||||
@@ -566,6 +637,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
||||
let width = ui.available_width();
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::slider(
|
||||
playback.duration.is_some(),
|
||||
playback.position,
|
||||
format!("Position, {}", ui::format_time(playback.position)),
|
||||
)
|
||||
});
|
||||
|
||||
let active = response.hovered() || response.dragged();
|
||||
// Grows rather than jumps when the pointer arrives.
|
||||
|
||||
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// A name for a screen reader, on a control whose face does not say it.
|
||||
///
|
||||
/// A button drawn as an icon glyph is announced as the icon font's private
|
||||
/// code point, and one painted by hand as nothing at all.
|
||||
pub trait Named {
|
||||
/// The control's name to a screen reader.
|
||||
fn named(self, name: impl Into<String>) -> Self;
|
||||
/// Hover text that is also the control's name.
|
||||
fn hint(self, text: impl Into<String>) -> Self;
|
||||
}
|
||||
|
||||
impl Named for egui::Response {
|
||||
fn named(self, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
let enabled = self.enabled();
|
||||
self.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||
self
|
||||
}
|
||||
|
||||
fn hint(self, text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
self.named(text.clone()).on_hover_text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// An on/off switch with its label beside it, for settings.
|
||||
///
|
||||
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
||||
@@ -98,6 +123,9 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
||||
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
||||
const SIZE: f32 = 20.0;
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, watched, "Watched")
|
||||
});
|
||||
if ui.is_rect_visible(rect) {
|
||||
let ctx = ui.ctx();
|
||||
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||
@@ -278,6 +306,12 @@ pub enum DownloadGlyph {
|
||||
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
||||
const SIZE: f32 = 30.0;
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||
let response = response.named(match glyph {
|
||||
DownloadGlyph::Download => "Download".to_owned(),
|
||||
DownloadGlyph::Progress(done) => format!("Downloading, {:.0}%", done * 100.0),
|
||||
DownloadGlyph::Paused(done) => format!("Download paused, {:.0}%", done * 100.0),
|
||||
DownloadGlyph::Done => "Downloaded".to_owned(),
|
||||
});
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return response;
|
||||
}
|
||||
@@ -356,6 +390,52 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
|
||||
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// A fact about the files rather than the title — 4K, HDR, Dual audio — as
|
||||
/// a small outlined mark: quieter than a [`chip`], which is something the
|
||||
/// title is filed under.
|
||||
pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
egui::Frame::new()
|
||||
.stroke(egui::Stroke::new(1.0, theme::muted().gamma_multiply(0.7)))
|
||||
.corner_radius(CornerRadius::same(theme::radius::BADGE))
|
||||
.inner_margin(egui::Margin::symmetric(5, 1))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
theme::Role::Micro
|
||||
.rich(text)
|
||||
.family(theme::semibold())
|
||||
.color(theme::muted()),
|
||||
);
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
/// Where a title stands — `Airing · episode 8 Sat 1 Nov, 14:00`, `Finished` —
|
||||
/// as a [`chip`] with a dot in front: the accent while more is coming, muted
|
||||
/// once it is all out.
|
||||
pub fn status_chip(ui: &mut egui::Ui, text: &str, live: bool) -> egui::Response {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card_hover().gamma_multiply(0.8))
|
||||
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::space::XS;
|
||||
let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(
|
||||
dot.center(),
|
||||
3.0,
|
||||
if live {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::muted()
|
||||
},
|
||||
);
|
||||
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||
});
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
/// A small rounded label: a genre, a year, a fact.
|
||||
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
egui::Frame::new()
|
||||
@@ -367,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
/// A [`chip`] that opens something: lighter under the pointer, with a ring
|
||||
/// when keyboard focus is on it.
|
||||
pub fn link_chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
let mut prepared = egui::Frame::new()
|
||||
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||
.begin(ui);
|
||||
prepared
|
||||
.content_ui
|
||||
.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||
let response = prepared
|
||||
.allocate_space(ui)
|
||||
.interact(egui::Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
prepared.frame.fill = if response.hovered() {
|
||||
theme::card_hover()
|
||||
} else {
|
||||
theme::card_hover().gamma_multiply(0.8)
|
||||
};
|
||||
if response.has_focus() {
|
||||
prepared.frame.stroke = egui::Stroke::new(1.5, theme::accent());
|
||||
}
|
||||
prepared.paint(ui);
|
||||
response
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
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::shares::AccountFolder;
|
||||
use pstr_core::sync::WatchSync;
|
||||
@@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let share = store
|
||||
.add(&name, &url, password.as_deref())
|
||||
let (share, _) = store
|
||||
.add_verified(&name, &url, password.as_deref())
|
||||
.await
|
||||
.context("add share")?;
|
||||
println!("added {} ({})", share.name, share.id);
|
||||
}
|
||||
@@ -323,11 +324,29 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
let sync = WatchSync::open(&dirs).context("open sync state")?;
|
||||
let catalog =
|
||||
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
|
||||
let report = sync.sync(&account, &catalog).await.context("sync")?;
|
||||
let report = sync
|
||||
.sync(&account, &catalog, &store)
|
||||
.await
|
||||
.context("sync")?;
|
||||
let profile = &report.profile;
|
||||
for id in &profile.shares_removed {
|
||||
catalog
|
||||
.lock()
|
||||
.remove_share(id)
|
||||
.context("drop a removed share")?;
|
||||
}
|
||||
println!(
|
||||
"read {} other device(s), took {} newer position(s), {}",
|
||||
"read {} other device(s), took {} newer position(s), \
|
||||
{} share(s) added, {} removed, settings {}, {}",
|
||||
report.devices,
|
||||
report.applied,
|
||||
profile.shares_added.len() + profile.shares_changed.len(),
|
||||
profile.shares_removed.len(),
|
||||
if profile.settings {
|
||||
"updated"
|
||||
} else {
|
||||
"unchanged"
|
||||
},
|
||||
if report.uploaded {
|
||||
"uploaded this device's history"
|
||||
} else {
|
||||
@@ -358,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
|
||||
Command::Remove { id } => {
|
||||
store.remove(&id).context("remove share")?;
|
||||
// Its catalog rows go too, or the library keeps listing its files —
|
||||
// beside the new share's, when the same folder was added back
|
||||
// another way.
|
||||
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||
let offline = catalog
|
||||
.offline_files_for_share(&id)
|
||||
.context("read the share's downloads")?;
|
||||
for (link_id, file) in &offline {
|
||||
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
|
||||
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
|
||||
return Err(error).context("delete a downloaded file");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
catalog
|
||||
.remove_share(&id)
|
||||
.context("drop the share's catalog rows")?;
|
||||
println!("removed {id}");
|
||||
}
|
||||
|
||||
@@ -388,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
|
||||
catalog
|
||||
.replace_share(&share_id, &rows)
|
||||
.and_then(|()| {
|
||||
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
|
||||
})
|
||||
.with_context(|| format!("store {share_id}"))?;
|
||||
|
||||
println!(
|
||||
@@ -506,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
settings.provider.label(),
|
||||
settings.provider.description()
|
||||
);
|
||||
if settings.provider.needs_api_key() {
|
||||
println!(
|
||||
"api key: {}",
|
||||
match pstr_meta::settings::api_key(settings.provider) {
|
||||
Some(_) => "stored in the credential store",
|
||||
None => "not set — run `pstr metadata set --api-key`",
|
||||
}
|
||||
);
|
||||
}
|
||||
let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some();
|
||||
println!(
|
||||
"tmdb key: {}",
|
||||
match (stored_key, settings.provider.needs_api_key()) {
|
||||
(true, true) => "stored in the credential store",
|
||||
(true, false) => "stored — used for the art of films TVDB has none for",
|
||||
(false, true) => "not set — run `pstr metadata set --api-key`",
|
||||
(false, false) => "not set (optional, for film art)",
|
||||
}
|
||||
);
|
||||
|
||||
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||
let stored = catalog.all_metadata()?;
|
||||
@@ -535,12 +576,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
api_key,
|
||||
} => {
|
||||
if let Some(name) = provider {
|
||||
settings.provider = ProviderId::parse(&name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown provider {name:?}; try one of: {}",
|
||||
ProviderId::ALL.map(|provider| provider.as_str()).join(", ")
|
||||
)
|
||||
})?;
|
||||
settings.provider = ProviderId::parse(&name)
|
||||
.filter(|provider| ProviderId::CHOOSABLE.contains(provider))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown provider {name:?}; try one of: {}",
|
||||
ProviderId::CHOOSABLE
|
||||
.map(|provider| provider.as_str())
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if on {
|
||||
settings.enabled = true;
|
||||
@@ -550,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
}
|
||||
|
||||
if api_key {
|
||||
let key = rpassword::prompt_password(format!(
|
||||
"{} API key (empty to forget it): ",
|
||||
settings.provider.label()
|
||||
))
|
||||
.context("read the API key")?;
|
||||
pstr_meta::settings::set_api_key(settings.provider, &key)
|
||||
// TMDB's is the only key there is: the provider with TMDB
|
||||
// chosen, and film art with AniList.
|
||||
let key = rpassword::prompt_password("TMDB API key (empty to forget it): ")
|
||||
.context("read the API key")?;
|
||||
pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key)
|
||||
.context("store the API key")?;
|
||||
}
|
||||
|
||||
@@ -566,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
// `Engine::set_metadata_config`.
|
||||
if off || settings.enabled {
|
||||
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||
let chain = pstr_meta::service::chain_of(settings.provider);
|
||||
let stale = catalog
|
||||
.all_metadata()?
|
||||
.values()
|
||||
.any(|record| record.provider != settings.provider);
|
||||
.any(|record| !chain.contains(&record.provider));
|
||||
if off || stale {
|
||||
catalog.clear_metadata()?;
|
||||
println!("cleared the stored answers");
|
||||
@@ -587,13 +632,13 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
anyhow::bail!(
|
||||
"lookups are off — run `pstr metadata set --on` first.\n\
|
||||
Matching sends the titles in your catalog to {}.",
|
||||
settings.provider.label()
|
||||
pstr_meta::service::chain_label(settings.provider)
|
||||
);
|
||||
}
|
||||
|
||||
let service = pstr_meta::MetadataService::new(
|
||||
&settings,
|
||||
pstr_meta::settings::api_key(settings.provider),
|
||||
pstr_meta::settings::api_key(ProviderId::Tmdb),
|
||||
)
|
||||
.context("build the metadata provider")?;
|
||||
|
||||
@@ -606,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
library.titles,
|
||||
&stored,
|
||||
&enriched,
|
||||
settings.provider,
|
||||
service.has_details(),
|
||||
&service.chain(),
|
||||
force,
|
||||
);
|
||||
if let Some(limit) = limit {
|
||||
@@ -621,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
println!(
|
||||
"looking up {} titles against {}…",
|
||||
pending.len(),
|
||||
settings.provider.label()
|
||||
service.label()
|
||||
);
|
||||
|
||||
// Serial, not concurrent: both providers rate-limit by the minute,
|
||||
@@ -651,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
Some(found) => {
|
||||
matched += 1;
|
||||
println!(
|
||||
" {name:<40} → {} ({})",
|
||||
" {name:<40} → {} ({}){}",
|
||||
found.name,
|
||||
found
|
||||
.year
|
||||
.map(|year| year.to_string())
|
||||
.unwrap_or_else(|| "-".into())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
if found.provider == service.provider() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" on {}", found.provider.label())
|
||||
}
|
||||
);
|
||||
}
|
||||
None => println!(" {name:<40} → no match"),
|
||||
@@ -677,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
);
|
||||
catalog.set_enrichment(
|
||||
&outcome.title_key,
|
||||
settings.provider,
|
||||
outcome.provider,
|
||||
unix_now(),
|
||||
enrichment,
|
||||
)?;
|
||||
|
||||
@@ -33,7 +33,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
||||
use proton_sdk::api::HumanVerificationCredential;
|
||||
use proton_sdk::account::KeyPassphrases;
|
||||
use proton_sdk::api::{HumanVerificationCredential, ResponseCode};
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
@@ -51,6 +52,12 @@ const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security
|
||||
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
|
||||
with its code.";
|
||||
|
||||
/// Why a sign-in Proton answered with [`ResponseCode::Blocked`] failed. The
|
||||
/// server's own text says the app version is no longer supported, which is
|
||||
/// not true and sends the viewer looking for an update that does not exist.
|
||||
const SIGN_IN_BLOCKED: &str = "Proton has blocked sign-ins from this app for now. Wait a while \
|
||||
and try again, or sign in once on proton.me first.";
|
||||
|
||||
/// Blocks in flight for streaming from the account, as for a public link —
|
||||
/// see `ShareStore::open`.
|
||||
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
||||
@@ -91,10 +98,10 @@ impl StoredSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn unlock(&self) -> HashMap<String, Vec<u8>> {
|
||||
fn unlock(&self) -> KeyPassphrases {
|
||||
self.key_passphrases
|
||||
.iter()
|
||||
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
|
||||
.map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -424,9 +431,9 @@ impl AccountStore {
|
||||
/// back the account.
|
||||
///
|
||||
/// The key passphrases are derived here, while the token still has the scope
|
||||
/// the key salts need. Loading the addresses is what proves the mailbox
|
||||
/// password: it unlocks the user keys, and a wrong password unlocks none of
|
||||
/// them. Only the passphrases are stored; the password goes no further.
|
||||
/// the key salts need. Unlocking the user keys is what proves the mailbox
|
||||
/// password: a wrong one unlocks none of them. Only the passphrases are
|
||||
/// stored; the password goes no further.
|
||||
async fn finish(
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
session: ProtonApiSession,
|
||||
@@ -435,10 +442,10 @@ async fn finish(
|
||||
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||
probe
|
||||
.account()
|
||||
.addresses()
|
||||
.unlock()
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
ProtonError::InvalidOperation(_) => {
|
||||
ProtonError::KeysLocked => {
|
||||
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||
}
|
||||
other => other.into(),
|
||||
@@ -447,10 +454,10 @@ async fn finish(
|
||||
.account()
|
||||
.key_passphrases()
|
||||
.await?
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|(id, passphrase)| {
|
||||
String::from_utf8(passphrase)
|
||||
.map(|passphrase| (id, passphrase))
|
||||
std::str::from_utf8(passphrase)
|
||||
.map(|passphrase| (id.to_owned(), passphrase.to_owned()))
|
||||
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>>>()?;
|
||||
@@ -500,7 +507,8 @@ fn persist_refreshed_tokens(
|
||||
});
|
||||
}
|
||||
|
||||
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
|
||||
/// Lift a CAPTCHA gate, a blocked sign-in and a wrong password out of the
|
||||
/// generic API error.
|
||||
///
|
||||
/// Only a gate that can be solved in an app becomes
|
||||
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
||||
@@ -512,6 +520,9 @@ fn classify_sign_in_failure(error: ProtonError) -> Error {
|
||||
{
|
||||
return Error::HumanVerification(Box::new(challenge));
|
||||
}
|
||||
if api.code == ResponseCode::Blocked {
|
||||
return Error::SignIn(SIGN_IN_BLOCKED.to_owned());
|
||||
}
|
||||
if api.http_status == 422 && !api.is_human_verification_required() {
|
||||
return Error::SignIn(if api.message.is_empty() {
|
||||
"Proton did not accept that address and password".to_owned()
|
||||
@@ -642,6 +653,27 @@ mod tests {
|
||||
assert!(store.resume().expect("resume").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_session_unlocks_with_its_passphrases() {
|
||||
let unlock = stored().unlock();
|
||||
assert_eq!(unlock.len(), 1);
|
||||
assert_eq!(unlock.get("key-1"), Some(b"derived".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blocked_sign_in_does_not_claim_the_app_is_outdated() {
|
||||
let error = classify_sign_in_failure(ProtonError::Api(proton_sdk::error::ProtonApiError {
|
||||
code: ResponseCode::Blocked,
|
||||
http_status: 422,
|
||||
message: "This version of the app is no longer supported".to_owned(),
|
||||
details: None,
|
||||
}));
|
||||
assert!(
|
||||
matches!(&error, Error::SignIn(text) if text == SIGN_IN_BLOCKED),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_session_keeps_its_password_mode() {
|
||||
let params = stored().resume_parameters();
|
||||
|
||||
@@ -280,6 +280,81 @@ pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
|
||||
/// a tab, a play glyph, never body copy.
|
||||
pub const MIN_CONTRAST: f32 = 3.0;
|
||||
|
||||
/// The colour a picture leaves behind: its pixels averaged, with the vivid
|
||||
/// ones counting for more. `None` for a picture with nothing opaque in it.
|
||||
///
|
||||
/// Weighted rather than a plain mean, because a plain mean of almost any
|
||||
/// still is a brownish grey — the sky and the shadows cancel out, and the one
|
||||
/// red coat that makes the poster what it is barely moves the sum. Each pixel
|
||||
/// counts once plus [`MOOD_CHROMA_WEIGHT`] times its chroma, so a saturated
|
||||
/// patch pulls the result towards itself without a single pixel deciding it.
|
||||
///
|
||||
/// Takes whatever pixels the caller has, in any order and at any size: every
|
||||
/// client hands over a picture already squeezed small, so this is a few
|
||||
/// hundred values, not a frame.
|
||||
pub fn mood(pixels: impl IntoIterator<Item = (Rgb, u8)>) -> Option<Rgb> {
|
||||
let (mut r, mut g, mut b, mut total) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
|
||||
for (pixel, alpha) in pixels {
|
||||
// Half transparent and below is a logo's anti-aliasing or a poster's
|
||||
// rounded corner, not the picture.
|
||||
if alpha < 128 {
|
||||
continue;
|
||||
}
|
||||
let high = pixel.r.max(pixel.g).max(pixel.b);
|
||||
let low = pixel.r.min(pixel.g).min(pixel.b);
|
||||
let weight = 1.0 + MOOD_CHROMA_WEIGHT * f32::from(high - low) / 255.0;
|
||||
r += weight * f32::from(pixel.r);
|
||||
g += weight * f32::from(pixel.g);
|
||||
b += weight * f32::from(pixel.b);
|
||||
total += weight;
|
||||
}
|
||||
(total > 0.0).then(|| {
|
||||
let channel = |sum: f32| (sum / total).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb::new(channel(r), channel(g), channel(b))
|
||||
})
|
||||
}
|
||||
|
||||
/// How much more a fully saturated pixel counts towards [`mood`] than a grey
|
||||
/// one.
|
||||
pub const MOOD_CHROMA_WEIGHT: f32 = 4.0;
|
||||
|
||||
/// The brightest channel a mood is brought down to before it tints a dark
|
||||
/// palette. Any brighter and the veil stops being the page with a hint of the
|
||||
/// picture in it, and text over it loses the contrast the veil is there for.
|
||||
pub const MOOD_DEPTH: u8 = 60;
|
||||
|
||||
/// How far a dark palette's veil moves from the page colour towards the mood.
|
||||
pub const MOOD_DARK: f32 = 0.55;
|
||||
|
||||
/// The same for a light palette — much less, because a light page tinted as
|
||||
/// hard reads as a coloured page rather than a lit one.
|
||||
pub const MOOD_LIGHT: f32 = 0.12;
|
||||
|
||||
/// The page colour, tinted by a picture's [`mood`]: what the veils over a
|
||||
/// title's backdrop are painted in, so each title's banner carries its own
|
||||
/// light rather than the same flat colour.
|
||||
///
|
||||
/// On a dark palette the mood is first darkened to [`MOOD_DEPTH`] — only its
|
||||
/// hue and saturation are wanted, never its brightness — and the page moves
|
||||
/// [`MOOD_DARK`] of the way to it. A light palette moves [`MOOD_LIGHT`] of the
|
||||
/// way to the mood as it is.
|
||||
pub fn mood_veil(background: Rgb, mood: Rgb, light: bool) -> Rgb {
|
||||
if light {
|
||||
return mix(background, mood, MOOD_LIGHT);
|
||||
}
|
||||
let high = mood.r.max(mood.g).max(mood.b);
|
||||
if high == 0 {
|
||||
return mix(background, mood, MOOD_DARK);
|
||||
}
|
||||
let scale = f32::from(MOOD_DEPTH.min(high)) / f32::from(high);
|
||||
let deep = |channel: u8| (f32::from(channel) * scale).round() as u8;
|
||||
mix(
|
||||
background,
|
||||
Rgb::new(deep(mood.r), deep(mood.g), deep(mood.b)),
|
||||
MOOD_DARK,
|
||||
)
|
||||
}
|
||||
|
||||
/// Every colour a client draws with, resolved from a [`Flavor`] and an
|
||||
/// [`Accent`].
|
||||
///
|
||||
@@ -627,6 +702,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_of_one_colour_has_that_colour_as_its_mood() {
|
||||
let teal = Rgb::new(20, 140, 150);
|
||||
assert_eq!(mood(std::iter::repeat_n((teal, 255), 16)), Some(teal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vivid_patch_outweighs_as_much_grey_in_the_mood() {
|
||||
let red = Rgb::new(220, 30, 30);
|
||||
let grey = Rgb::new(120, 120, 120);
|
||||
let found = mood([(red, 255), (grey, 255)]).unwrap();
|
||||
assert!(found.r > 180, "{found:?}");
|
||||
assert!(found.g < 60 && found.b < 60, "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pixels_do_not_count_towards_the_mood() {
|
||||
let blue = Rgb::new(30, 60, 200);
|
||||
assert_eq!(
|
||||
mood([(blue, 255), (Rgb::new(255, 255, 255), 0)]),
|
||||
Some(blue)
|
||||
);
|
||||
assert_eq!(mood([(blue, 10)]), None);
|
||||
assert_eq!(mood(std::iter::empty()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dark_veil_takes_the_moods_hue_but_never_its_brightness() {
|
||||
let background = Rgb::new(11, 11, 15);
|
||||
let veil = mood_veil(background, Rgb::new(255, 240, 40), false);
|
||||
// Warmer than the page…
|
||||
assert!(veil.r > background.r && veil.g > background.g, "{veil:?}");
|
||||
// …and still dark enough to be a page under white text.
|
||||
assert!(luminance(veil) < 0.05, "{veil:?}");
|
||||
assert!(contrast(veil, Rgb::new(255, 255, 255)) > 12.0, "{veil:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_light_veil_moves_only_a_little_way_to_the_mood() {
|
||||
let background = Rgb::new(239, 241, 245);
|
||||
let veil = mood_veil(background, Rgb::new(200, 20, 20), true);
|
||||
assert!(veil.r >= veil.g, "{veil:?}");
|
||||
assert!(luminance(veil) > 0.6, "{veil:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_run_gets_the_palette_the_app_shipped_with() {
|
||||
let appearance = Appearance::default();
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
|
||||
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::mylist::MyList;
|
||||
|
||||
/// What order a grid is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sort {
|
||||
/// By the name shown, ignoring a leading article.
|
||||
#[default]
|
||||
@@ -50,6 +53,70 @@ 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,
|
||||
/// On My List.
|
||||
MyList,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::All,
|
||||
Self::MyList,
|
||||
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",
|
||||
Self::MyList => "My List",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
list: &MyList,
|
||||
) -> 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)),
|
||||
Self::MyList => list.contains(&title.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name to show for `title`: the provider's, in the form the viewer
|
||||
/// picked, else the share's.
|
||||
pub fn display_name<'a>(
|
||||
@@ -256,6 +323,7 @@ mod tests {
|
||||
kind: TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: Some(rating),
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
@@ -392,4 +460,61 @@ mod tests {
|
||||
assert_eq!(found[0].key, "sousou no frieren");
|
||||
assert_eq!(search(&library, &metadata, "").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filters_split_the_library_by_kind_and_progress() {
|
||||
let film = title("f", "Film", TitleKind::Film);
|
||||
let series = title("s", "Series", TitleKind::Series);
|
||||
let none = |_: &CatalogNode| false;
|
||||
let list = MyList::default();
|
||||
assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
|
||||
assert!(
|
||||
Filter::Films.admits(&film, none, &list) && !Filter::Films.admits(&series, none, &list)
|
||||
);
|
||||
assert!(
|
||||
Filter::Series.admits(&series, none, &list)
|
||||
&& !Filter::Series.admits(&film, none, &list)
|
||||
);
|
||||
// Nothing watched of either: not started, and not being watched.
|
||||
assert!(Filter::Unwatched.admits(&film, none, &list));
|
||||
assert!(!Filter::Watching.admits(&film, none, &list));
|
||||
}
|
||||
|
||||
#[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];
|
||||
|
||||
let list = MyList::default();
|
||||
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b", &list));
|
||||
assert!(!Filter::Downloaded.admits(show, |_| false, &list));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_my_list_filter_admits_only_what_is_on_the_list() {
|
||||
let film = title("f", "Film", TitleKind::Film);
|
||||
let series = title("s", "Series", TitleKind::Series);
|
||||
let mut list = MyList::default();
|
||||
list.add("s", 1);
|
||||
assert!(Filter::MyList.admits(&series, |_| false, &list));
|
||||
assert!(!Filter::MyList.admits(&film, |_| false, &list));
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
|
||||
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
||||
}
|
||||
|
||||
/// A stretch of a file someone has marked as its opening or its ending, for
|
||||
/// a file whose own chapters do not say: AniSkip's answer, in the file's
|
||||
/// seconds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SkipRange {
|
||||
/// [`ChapterRole::Opening`] or [`ChapterRole::Ending`].
|
||||
pub role: ChapterRole,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
impl SkipRange {
|
||||
/// What the chapter cut for it is called, which is also what [`roles`]
|
||||
/// reads it back as.
|
||||
fn label(self) -> Option<&'static str> {
|
||||
match self.role {
|
||||
ChapterRole::Opening => Some("Opening"),
|
||||
ChapterRole::Ending => Some("Ending"),
|
||||
ChapterRole::Preview | ChapterRole::Content => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How close two chapter starts may be before they are the same place.
|
||||
const SAME_PLACE_SECONDS: f64 = 1.0;
|
||||
|
||||
/// The file's chapters with `ranges` cut into them, as chapters named
|
||||
/// `Opening` and `Ending` — so that skipping, skipping on its own, the up-next
|
||||
/// countdown and the marks on the seek bar all work for a file that came with
|
||||
/// no such chapters, through the same code as one that did.
|
||||
///
|
||||
/// **The file wins.** When its own chapters already mark an opening or an
|
||||
/// ending, it is returned as it is: what the release says about its own cut
|
||||
/// beats what a viewer of some other release said. A file chapter that starts
|
||||
/// inside a range is dropped, since it would split the range in two; one that
|
||||
/// starts where a range ends gives that place its name. Every chapter is
|
||||
/// numbered again from zero, which is what the players key "already skipped"
|
||||
/// by.
|
||||
pub fn with_skip_ranges(
|
||||
chapters: &[Chapter],
|
||||
ranges: &[SkipRange],
|
||||
duration: Option<f64>,
|
||||
) -> Vec<Chapter> {
|
||||
let own = roles(chapters, duration);
|
||||
if ranges.is_empty()
|
||||
|| own
|
||||
.iter()
|
||||
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending))
|
||||
{
|
||||
return chapters.to_vec();
|
||||
}
|
||||
|
||||
let mut ranges: Vec<SkipRange> = ranges
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|range| range.label().is_some() && range.start >= 0.0 && range.end > range.start)
|
||||
.collect();
|
||||
ranges.sort_by(|a, b| a.start.total_cmp(&b.start));
|
||||
// Two answers that overlap cannot both be right; the earlier one stays.
|
||||
let mut kept: Vec<SkipRange> = Vec::with_capacity(ranges.len());
|
||||
for range in ranges {
|
||||
if kept.last().is_none_or(|last| range.start >= last.end) {
|
||||
kept.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
let title_at = |position: f64| {
|
||||
chapter_at(chapters, position).and_then(|index| chapters[index].title.clone())
|
||||
};
|
||||
let mut points: Vec<(f64, Option<String>)> = Vec::new();
|
||||
for range in &kept {
|
||||
points.push((range.start, range.label().map(str::to_owned)));
|
||||
let ends_the_file =
|
||||
duration.is_some_and(|duration| range.end >= duration - SAME_PLACE_SECONDS);
|
||||
if !ends_the_file {
|
||||
points.push((range.end, title_at(range.end)));
|
||||
}
|
||||
}
|
||||
let near_a_point = |start: f64, points: &[(f64, Option<String>)]| {
|
||||
points
|
||||
.iter()
|
||||
.any(|(at, _)| (at - start).abs() < SAME_PLACE_SECONDS)
|
||||
};
|
||||
let inside_a_range = |start: f64| {
|
||||
kept.iter()
|
||||
.any(|range| start > range.start && start < range.end)
|
||||
};
|
||||
let from_ranges = points.clone();
|
||||
for chapter in chapters {
|
||||
if !near_a_point(chapter.start, &from_ranges) && !inside_a_range(chapter.start) {
|
||||
points.push((chapter.start, chapter.title.clone()));
|
||||
}
|
||||
}
|
||||
// Whatever comes before the first mark is a place in the file too.
|
||||
if !points.iter().any(|(at, _)| *at < SAME_PLACE_SECONDS) {
|
||||
points.push((0.0, None));
|
||||
}
|
||||
points.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
points
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (start, title))| Chapter {
|
||||
index: index as i64,
|
||||
title,
|
||||
start,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -480,4 +590,123 @@ mod tests {
|
||||
// and a button that seeks to zero would be worse than no button.
|
||||
assert_eq!(chapter_end(&chapters, 1, None), None);
|
||||
}
|
||||
|
||||
fn range(role: ChapterRole, start: f64, end: f64) -> SkipRange {
|
||||
SkipRange { role, start, end }
|
||||
}
|
||||
|
||||
fn shape(chapters: &[Chapter]) -> Vec<(i64, Option<&str>, f64)> {
|
||||
chapters
|
||||
.iter()
|
||||
.map(|chapter| (chapter.index, chapter.title.as_deref(), chapter.start))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_chapters_gets_its_opening_and_ending_as_chapters_that_skip() {
|
||||
let ranges = [
|
||||
range(ChapterRole::Opening, 90.0, 180.0),
|
||||
range(ChapterRole::Ending, 1300.0, 1390.0),
|
||||
];
|
||||
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
|
||||
assert_eq!(
|
||||
shape(&chapters),
|
||||
vec![
|
||||
(0, None, 0.0),
|
||||
(1, Some("Opening"), 90.0),
|
||||
(2, None, 180.0),
|
||||
(3, Some("Ending"), 1300.0),
|
||||
(4, None, 1390.0),
|
||||
]
|
||||
);
|
||||
let roles = roles(&chapters, Some(1420.0));
|
||||
assert_eq!(roles[1], ChapterRole::Opening);
|
||||
assert_eq!(roles[3], ChapterRole::Ending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_own_opening_beats_anything_aniskip_says() {
|
||||
let own = vec![
|
||||
Chapter {
|
||||
index: 0,
|
||||
title: Some("Prologue".into()),
|
||||
start: 0.0,
|
||||
},
|
||||
Chapter {
|
||||
index: 1,
|
||||
title: Some("OP".into()),
|
||||
start: 60.0,
|
||||
},
|
||||
Chapter {
|
||||
index: 2,
|
||||
title: Some("Part A".into()),
|
||||
start: 150.0,
|
||||
},
|
||||
];
|
||||
let ranges = [range(ChapterRole::Opening, 70.0, 160.0)];
|
||||
assert_eq!(with_skip_ranges(&own, &ranges, Some(1420.0)), own);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_chapters_inside_a_range_give_way_and_one_at_its_end_keeps_its_name() {
|
||||
let own = vec![
|
||||
Chapter {
|
||||
index: 0,
|
||||
title: None,
|
||||
start: 0.0,
|
||||
},
|
||||
Chapter {
|
||||
index: 1,
|
||||
title: None,
|
||||
start: 89.6,
|
||||
},
|
||||
Chapter {
|
||||
index: 2,
|
||||
title: None,
|
||||
start: 120.0,
|
||||
},
|
||||
Chapter {
|
||||
index: 3,
|
||||
title: Some("Part A".into()),
|
||||
start: 180.0,
|
||||
},
|
||||
];
|
||||
let ranges = [range(ChapterRole::Opening, 90.0, 180.0)];
|
||||
let chapters = with_skip_ranges(&own, &ranges, Some(1420.0));
|
||||
assert_eq!(
|
||||
shape(&chapters),
|
||||
vec![
|
||||
(0, None, 0.0),
|
||||
(1, Some("Opening"), 90.0),
|
||||
(2, Some("Part A"), 180.0)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ending_that_runs_to_the_end_of_the_file_adds_no_chapter_after_it() {
|
||||
let ranges = [range(ChapterRole::Ending, 1330.0, 1419.8)];
|
||||
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
|
||||
assert_eq!(
|
||||
shape(&chapters),
|
||||
vec![(0, None, 0.0), (1, Some("Ending"), 1330.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
credits_start(&chapters, &roles(&chapters, Some(1420.0))),
|
||||
Some(1330.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_answers_keep_the_earlier_one() {
|
||||
let ranges = [
|
||||
range(ChapterRole::Opening, 0.0, 90.0),
|
||||
range(ChapterRole::Ending, 60.0, 150.0),
|
||||
];
|
||||
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
|
||||
assert_eq!(
|
||||
shape(&chapters),
|
||||
vec![(0, Some("Opening"), 0.0), (1, None, 90.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! Getting from one title to the next: "More like this", the "Because you
|
||||
//! watched" row, and the pages a genre, studio or person chip opens.
|
||||
//!
|
||||
//! All of it is read off what the provider already said about each title —
|
||||
//! genres, tags, studios, directors, format — so none of it costs a request,
|
||||
//! and it only ever offers what the library holds.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::library::Title;
|
||||
use crate::metadata::{MetadataRecord, TitleMetadata, TitleNames};
|
||||
use crate::shelves::Shelf;
|
||||
|
||||
/// What a chip on a title page stands for, and the page it opens.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Facet {
|
||||
Genre(String),
|
||||
Studio(String),
|
||||
/// A director, or a voice actor in the cast.
|
||||
Person(String),
|
||||
}
|
||||
|
||||
impl Facet {
|
||||
/// The name on the chip, and the heading of its page.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Genre(name) | Self::Studio(name) | Self::Person(name) => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of thing the name is, said over the heading.
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Genre(_) => "Genre",
|
||||
Self::Studio(_) => "Studio",
|
||||
Self::Person(_) => "Person",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a title the provider described as `found` belongs on the page.
|
||||
pub fn admits(&self, found: &TitleMetadata) -> bool {
|
||||
let details = found.details.as_ref();
|
||||
match self {
|
||||
Self::Genre(name) => found.genres.iter().any(|genre| genre == name),
|
||||
Self::Studio(name) => {
|
||||
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
|
||||
}
|
||||
Self::Person(name) => details.is_some_and(|details| {
|
||||
details.directors.iter().any(|person| person == name)
|
||||
|| details
|
||||
.cast
|
||||
.iter()
|
||||
.any(|member| member.actor.as_deref() == Some(name.as_str()))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of the titles in `titles` the page lists, in library order.
|
||||
pub fn titles(
|
||||
&self,
|
||||
titles: &[Title],
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
) -> Vec<String> {
|
||||
titles
|
||||
.iter()
|
||||
.filter(|title| {
|
||||
metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.is_some_and(|found| self.admits(found))
|
||||
})
|
||||
.map(|title| title.key.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One episode a search found by the name the provider gave it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EpisodeMatch {
|
||||
pub title_key: String,
|
||||
pub share_id: String,
|
||||
pub link_id: String,
|
||||
/// `S01E05`, as the row everywhere else says it.
|
||||
pub label: String,
|
||||
/// The provider's name for it.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What a search found besides titles: episodes by name, and the people,
|
||||
/// studios and genres whose names hold the query, each with how many titles
|
||||
/// in the library carry it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Found {
|
||||
pub episodes: Vec<EpisodeMatch>,
|
||||
/// Directors and studios.
|
||||
pub people: Vec<(Facet, usize)>,
|
||||
pub genres: Vec<(Facet, usize)>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.episodes.is_empty() && self.people.is_empty() && self.genres.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many episodes a search lists: past this the query is too loose for a
|
||||
/// list of episodes to help.
|
||||
const EPISODES: usize = 12;
|
||||
|
||||
/// Everything but titles that `query` finds. Nothing for a query under two
|
||||
/// letters, which would hold half of every name.
|
||||
pub fn find(
|
||||
library: &crate::library::Library,
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
guides: &HashMap<String, crate::metadata::EpisodeGuide>,
|
||||
query: &str,
|
||||
) -> Found {
|
||||
let needle = query.trim().to_lowercase();
|
||||
if needle.chars().count() < 2 {
|
||||
return Found::default();
|
||||
}
|
||||
let holds = |name: &str| name.to_lowercase().contains(&needle);
|
||||
|
||||
let mut episodes = Vec::new();
|
||||
'titles: for title in &library.titles {
|
||||
let Some(guide) = guides.get(&title.key) else {
|
||||
continue;
|
||||
};
|
||||
for episode in title.episodes() {
|
||||
let node = &episode.node;
|
||||
let Some(name) = node
|
||||
.parsed
|
||||
.episode
|
||||
.and_then(|number| guide.get(node.parsed.season, number))
|
||||
.and_then(|found| found.name.as_deref())
|
||||
.filter(|name| holds(name))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
episodes.push(EpisodeMatch {
|
||||
title_key: title.key.clone(),
|
||||
share_id: node.share_id.clone(),
|
||||
link_id: node.link_id.clone(),
|
||||
label: episode.numbering().unwrap_or_else(|| episode.label()),
|
||||
name: name.to_owned(),
|
||||
});
|
||||
if episodes.len() == EPISODES {
|
||||
break 'titles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut people: HashMap<Facet, usize> = HashMap::new();
|
||||
let mut genres: HashMap<Facet, usize> = HashMap::new();
|
||||
for title in &library.titles {
|
||||
let Some(found) = metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for genre in found.genres.iter().filter(|genre| holds(genre)) {
|
||||
*genres.entry(Facet::Genre(genre.clone())).or_default() += 1;
|
||||
}
|
||||
let Some(details) = &found.details else {
|
||||
continue;
|
||||
};
|
||||
// One count per title, however many characters a voice actor has
|
||||
// in it.
|
||||
let made: HashSet<Facet> = details
|
||||
.directors
|
||||
.iter()
|
||||
.chain(
|
||||
details
|
||||
.cast
|
||||
.iter()
|
||||
.filter_map(|member| member.actor.as_ref()),
|
||||
)
|
||||
.map(|name| Facet::Person(name.clone()))
|
||||
.chain(
|
||||
details
|
||||
.studios
|
||||
.iter()
|
||||
.map(|name| Facet::Studio(name.clone())),
|
||||
)
|
||||
.filter(|facet| holds(facet.name()))
|
||||
.collect();
|
||||
for facet in made {
|
||||
*people.entry(facet).or_default() += 1;
|
||||
}
|
||||
}
|
||||
// Most titles first, then by name, so the order does not change from one
|
||||
// keystroke to the next.
|
||||
let ranked = |found: HashMap<Facet, usize>| {
|
||||
let mut found: Vec<(Facet, usize)> = found.into_iter().collect();
|
||||
found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name())));
|
||||
found
|
||||
};
|
||||
Found {
|
||||
episodes,
|
||||
people: ranked(people),
|
||||
genres: ranked(genres),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much one shared trait says two titles are alike. A director is close
|
||||
/// to a signature; a genre is one of three or four on every entry.
|
||||
const DIRECTOR: u32 = 4;
|
||||
const STUDIO: u32 = 3;
|
||||
const GENRE: u32 = 2;
|
||||
const TAG: u32 = 1;
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
/// How many of a title's tags count. A provider ranks them, and the tail of
|
||||
/// the list ("Male Protagonist", "Primarily Teen Cast") is on half of
|
||||
/// everything.
|
||||
const TAGS: usize = 8;
|
||||
|
||||
/// The least score a title needs to be offered: one director, or a studio and
|
||||
/// a tag, or two genres. One genre in common is not a recommendation.
|
||||
const MIN_SCORE: u32 = 4;
|
||||
|
||||
/// What a title is, for comparing: only the parts that score.
|
||||
struct Traits<'a> {
|
||||
genres: HashSet<&'a str>,
|
||||
tags: HashSet<&'a str>,
|
||||
studios: HashSet<&'a str>,
|
||||
directors: HashSet<&'a str>,
|
||||
format: Option<&'a str>,
|
||||
popularity: u32,
|
||||
}
|
||||
|
||||
impl<'a> Traits<'a> {
|
||||
fn of(found: &'a TitleMetadata) -> Self {
|
||||
let details = found.details.as_ref();
|
||||
let strings = |list: Option<&'a Vec<String>>, take: usize| -> HashSet<&'a str> {
|
||||
list.into_iter()
|
||||
.flatten()
|
||||
.take(take)
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
};
|
||||
Self {
|
||||
genres: found.genres.iter().map(String::as_str).collect(),
|
||||
tags: strings(details.map(|details| &details.tags), TAGS),
|
||||
studios: strings(details.map(|details| &details.studios), usize::MAX),
|
||||
directors: strings(details.map(|details| &details.directors), usize::MAX),
|
||||
format: details.and_then(|details| details.format.as_deref()),
|
||||
popularity: details.and_then(|details| details.popularity).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn score(&self, other: &Self) -> u32 {
|
||||
let shared = |a: &HashSet<&str>, b: &HashSet<&str>| a.intersection(b).count() as u32;
|
||||
let traits = DIRECTOR * shared(&self.directors, &other.directors)
|
||||
+ STUDIO * shared(&self.studios, &other.studios)
|
||||
+ GENRE * shared(&self.genres, &other.genres)
|
||||
+ TAG * shared(&self.tags, &other.tags);
|
||||
// Only on top of something else: a TV series is like every other one.
|
||||
if traits > 0 && self.format.is_some() && self.format == other.format {
|
||||
traits + FORMAT
|
||||
} else {
|
||||
traits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to `limit` keys of the titles most like `key`, best first.
|
||||
///
|
||||
/// Never its own franchise — that is the row above it — and one title per
|
||||
/// other franchise, its best scoring: three films of one story are one
|
||||
/// suggestion, not three.
|
||||
pub fn more_like(
|
||||
key: &str,
|
||||
titles: &[Title],
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
limit: usize,
|
||||
) -> Vec<String> {
|
||||
ranked(key, titles, metadata, |_| true, limit)
|
||||
}
|
||||
|
||||
/// The row seeded by the title most recently watched to the end: titles like
|
||||
/// it the viewer has not started. `None` when nothing is finished, or nothing
|
||||
/// is like it.
|
||||
pub fn because_you_watched(
|
||||
titles: &[Title],
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
names: TitleNames,
|
||||
limit: usize,
|
||||
) -> Option<Shelf> {
|
||||
let seed = titles
|
||||
.iter()
|
||||
.filter(|title| {
|
||||
title.episode_count() > 0
|
||||
&& title.watched_count() == title.episode_count()
|
||||
&& metadata
|
||||
.get(&title.key)
|
||||
.is_some_and(|record| record.metadata.is_some())
|
||||
})
|
||||
.max_by_key(|title| title.last_played())?;
|
||||
let keys = ranked(
|
||||
&seed.key,
|
||||
titles,
|
||||
metadata,
|
||||
|title| title.last_played() == 0,
|
||||
limit,
|
||||
);
|
||||
(!keys.is_empty()).then(|| Shelf {
|
||||
label: format!(
|
||||
"Because you watched {}",
|
||||
crate::browse::display_name(seed, metadata, names)
|
||||
),
|
||||
keys,
|
||||
})
|
||||
}
|
||||
|
||||
fn ranked(
|
||||
key: &str,
|
||||
titles: &[Title],
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
admit: impl Fn(&Title) -> bool,
|
||||
limit: usize,
|
||||
) -> Vec<String> {
|
||||
let found = |title: &Title| {
|
||||
metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
};
|
||||
let Some(seed) = titles
|
||||
.iter()
|
||||
.find(|title| title.key == key)
|
||||
.and_then(found)
|
||||
.map(Traits::of)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let refs: Vec<&Title> = titles.iter().collect();
|
||||
let franchise_of: Vec<usize> = {
|
||||
let mut of = vec![0; titles.len()];
|
||||
for (at, franchise) in crate::franchise::group(&refs, metadata)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
for member in franchise.members {
|
||||
of[member] = at;
|
||||
}
|
||||
}
|
||||
of
|
||||
};
|
||||
let own = titles
|
||||
.iter()
|
||||
.position(|title| title.key == key)
|
||||
.map(|at| franchise_of[at]);
|
||||
|
||||
// The best of each franchise: (score, popularity, index).
|
||||
let mut best: HashMap<usize, (u32, u32, usize)> = HashMap::new();
|
||||
for (index, title) in titles.iter().enumerate() {
|
||||
if Some(franchise_of[index]) == own || !admit(title) {
|
||||
continue;
|
||||
}
|
||||
let Some(traits) = found(title).map(Traits::of) else {
|
||||
continue;
|
||||
};
|
||||
let score = seed.score(&traits);
|
||||
if score < MIN_SCORE {
|
||||
continue;
|
||||
}
|
||||
let candidate = (score, traits.popularity, index);
|
||||
best.entry(franchise_of[index])
|
||||
.and_modify(|held| {
|
||||
if (candidate.0, candidate.1) > (held.0, held.1) {
|
||||
*held = candidate;
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
}
|
||||
let mut picked: Vec<(u32, u32, usize)> = best.into_values().collect();
|
||||
// Best score, then most followed, then library order for a stable row.
|
||||
picked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
|
||||
picked
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, _, index)| titles[index].key.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{CatalogNode, WatchState};
|
||||
use crate::library::{Library, TitleKind};
|
||||
use crate::metadata::{ProviderId, TitleDetails};
|
||||
|
||||
struct Entry {
|
||||
key: &'static str,
|
||||
genres: &'static [&'static str],
|
||||
studio: &'static str,
|
||||
director: &'static str,
|
||||
related: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn record(entry: &Entry) -> (String, MetadataRecord) {
|
||||
(
|
||||
entry.key.into(),
|
||||
MetadataRecord {
|
||||
title_key: entry.key.into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(TitleMetadata {
|
||||
provider: ProviderId::AniList,
|
||||
remote_id: entry.key.into(),
|
||||
name: entry.key.into(),
|
||||
original_name: None,
|
||||
overview: None,
|
||||
year: None,
|
||||
kind: TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: entry.genres.iter().map(|genre| genre.to_string()).collect(),
|
||||
episodes: None,
|
||||
url: None,
|
||||
details: Some(TitleDetails {
|
||||
studios: vec![entry.studio.into()],
|
||||
directors: vec![entry.director.into()],
|
||||
related: entry.related.iter().map(|id| id.to_string()).collect(),
|
||||
format: Some("TV".into()),
|
||||
..TitleDetails::default()
|
||||
}),
|
||||
}),
|
||||
fetched_at: 0,
|
||||
manual: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn file(key: &str) -> CatalogNode {
|
||||
let name = format!("{key} - 01.mkv");
|
||||
CatalogNode {
|
||||
share_id: "s".into(),
|
||||
link_id: key.into(),
|
||||
volume_id: "v".into(),
|
||||
parent_link_id: None,
|
||||
name: name.clone(),
|
||||
is_folder: false,
|
||||
media_type: Some("video/x-matroska".into()),
|
||||
size: Some(1),
|
||||
active_revision_id: Some("r".into()),
|
||||
parsed: crate::naming::parse(&name),
|
||||
}
|
||||
}
|
||||
|
||||
const ENTRIES: [Entry; 5] = [
|
||||
Entry {
|
||||
key: "frieren",
|
||||
genres: &["Adventure", "Drama", "Fantasy"],
|
||||
studio: "Madhouse",
|
||||
director: "Keiichirou Saitou",
|
||||
related: &[],
|
||||
},
|
||||
Entry {
|
||||
key: "frieren film",
|
||||
genres: &["Adventure", "Drama", "Fantasy"],
|
||||
studio: "Madhouse",
|
||||
director: "Keiichirou Saitou",
|
||||
related: &["frieren"],
|
||||
},
|
||||
Entry {
|
||||
key: "bocchi",
|
||||
genres: &["Comedy", "Music"],
|
||||
studio: "CloverWorks",
|
||||
director: "Keiichirou Saitou",
|
||||
related: &[],
|
||||
},
|
||||
Entry {
|
||||
key: "mushishi",
|
||||
genres: &["Adventure", "Drama", "Fantasy", "Mystery"],
|
||||
studio: "Artland",
|
||||
director: "Hiroshi Nagahama",
|
||||
related: &[],
|
||||
},
|
||||
Entry {
|
||||
key: "lucky star",
|
||||
genres: &["Comedy"],
|
||||
studio: "Kyoto Animation",
|
||||
director: "Yasuhiro Takemoto",
|
||||
related: &[],
|
||||
},
|
||||
];
|
||||
|
||||
fn library(watched: &[&str]) -> (Library, HashMap<String, MetadataRecord>) {
|
||||
let watch: HashMap<(String, String), WatchState> = watched
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(at, key)| {
|
||||
(
|
||||
("s".to_owned(), key.to_string()),
|
||||
WatchState {
|
||||
position_secs: 0.0,
|
||||
duration_secs: Some(1_440.0),
|
||||
watched: true,
|
||||
updated_at: 100 + at as i64,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let library = Library::build(
|
||||
ENTRIES.iter().map(|entry| file(entry.key)).collect(),
|
||||
&watch,
|
||||
);
|
||||
// The library's keys are the folder names, normalised; the fixture's
|
||||
// already are.
|
||||
(library, ENTRIES.iter().map(record).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_like_this_leaves_out_the_franchise_and_ranks_a_shared_director_and_genres() {
|
||||
let (library, metadata) = library(&[]);
|
||||
let similar = more_like("frieren", &library.titles, &metadata, 10);
|
||||
// Mushishi shares three genres, Bocchi only the director; both the
|
||||
// format.
|
||||
// Lucky Star shares nothing and its film is Frieren's own franchise.
|
||||
assert_eq!(similar, ["mushishi", "bocchi"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_genre_in_common_is_not_a_recommendation() {
|
||||
let (library, metadata) = library(&[]);
|
||||
assert!(more_like("lucky star", &library.titles, &metadata, 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn because_you_watched_follows_the_last_finished_title_and_skips_what_was_started() {
|
||||
let (library, metadata) = library(&["mushishi", "frieren"]);
|
||||
let row =
|
||||
because_you_watched(&library.titles, &metadata, TitleNames::default(), 10).unwrap();
|
||||
assert_eq!(row.label, "Because you watched frieren");
|
||||
// Mushishi is like it, but already watched.
|
||||
assert_eq!(row.keys, ["bocchi"]);
|
||||
assert!(
|
||||
because_you_watched(&library.titles, &HashMap::new(), TitleNames::default(), 10)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_finds_episodes_by_name_and_the_people_and_genres_it_names() {
|
||||
let (library, metadata) = library(&[]);
|
||||
let guide = crate::metadata::EpisodeGuide::new(vec![crate::metadata::EpisodeMetadata {
|
||||
season: None,
|
||||
number: 1,
|
||||
absolute: None,
|
||||
entry_season: None,
|
||||
entry_number: None,
|
||||
name: Some("The Journey's End".into()),
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
runtime: None,
|
||||
mal_id: None,
|
||||
}]);
|
||||
let guides = HashMap::from([("frieren".to_owned(), guide)]);
|
||||
|
||||
let found = find(&library, &metadata, &guides, "journey");
|
||||
assert_eq!(found.episodes.len(), 1);
|
||||
assert_eq!(found.episodes[0].title_key, "frieren");
|
||||
assert_eq!(found.episodes[0].name, "The Journey's End");
|
||||
|
||||
let found = find(&library, &metadata, &guides, "saitou");
|
||||
assert_eq!(
|
||||
found.people,
|
||||
[(Facet::Person("Keiichirou Saitou".into()), 3)]
|
||||
);
|
||||
let found = find(&library, &metadata, &guides, "com");
|
||||
assert_eq!(found.genres, [(Facet::Genre("Comedy".into()), 2)]);
|
||||
assert!(find(&library, &metadata, &guides, "c").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_facet_page_lists_the_titles_that_carry_it() {
|
||||
let (library, metadata) = library(&[]);
|
||||
let person = Facet::Person("Keiichirou Saitou".into());
|
||||
assert_eq!(
|
||||
person.titles(&library.titles, &metadata).len(),
|
||||
3,
|
||||
"both Frieren titles and Bocchi"
|
||||
);
|
||||
assert_eq!(person.kind(), "Person");
|
||||
let genre = Facet::Genre("Comedy".into());
|
||||
let mut comedy = genre.titles(&library.titles, &metadata);
|
||||
comedy.sort();
|
||||
assert_eq!(comedy, ["bocchi", "lucky star"]);
|
||||
assert!(
|
||||
Facet::Studio("MAPPA".into())
|
||||
.titles(&library.titles, &metadata)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,15 +46,53 @@ pub enum Error {
|
||||
/// mailbox password.
|
||||
#[error("{0}")]
|
||||
SignIn(String),
|
||||
|
||||
/// A public link with a password of its own was opened without one. The
|
||||
/// handshake refusal says nothing a viewer can act on; this does.
|
||||
#[error("This link has a password of its own. Enter it to add the link.")]
|
||||
PasswordNeeded,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// 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
|
||||
/// was revoked or expired, or the token lacks a scope only a password
|
||||
/// sign-in grants. Nothing but signing in again fixes it.
|
||||
/// was revoked or expired, the token lacks a scope only a password
|
||||
/// sign-in grants, or the stored key passphrases no longer unlock the
|
||||
/// account because its mailbox password changed. Nothing but signing in
|
||||
/// again fixes it.
|
||||
pub fn is_signed_out(&self) -> bool {
|
||||
match self {
|
||||
Self::SignedOut(_) => true,
|
||||
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
|
||||
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
||||
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
||||
}
|
||||
@@ -71,3 +109,31 @@ impl From<keyring::Error> for 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_passphrases_that_no_longer_unlock_mean_signed_out() {
|
||||
let error = Error::Drive(proton_sdk::error::ProtonError::KeysLocked);
|
||||
assert!(error.is_signed_out());
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
kind: TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,21 @@ pub mod browse;
|
||||
pub mod catalog;
|
||||
pub mod chapters;
|
||||
pub mod config;
|
||||
pub mod discover;
|
||||
pub mod error;
|
||||
pub mod franchise;
|
||||
pub mod language;
|
||||
pub mod library;
|
||||
pub mod metadata;
|
||||
pub mod mylist;
|
||||
pub mod naming;
|
||||
pub mod news;
|
||||
pub mod prefs;
|
||||
pub mod profile;
|
||||
pub mod recent;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod subtitles;
|
||||
pub mod sync;
|
||||
|
||||
pub use account::{Account, AccountStore};
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::catalog::{CatalogNode, WatchState};
|
||||
use crate::naming;
|
||||
|
||||
/// A file has been watched once playback passed this fraction of it. Short of
|
||||
/// 1.0 because nobody sits through the credits, and a title that never reaches
|
||||
@@ -158,6 +159,34 @@ impl Title {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// What most of its files' names say about their quality — see
|
||||
/// [`naming::quality`]. A fact holds when more than half the files state
|
||||
/// it, so one 720p special does not mark a 1080p show down.
|
||||
pub fn quality(&self) -> naming::Quality {
|
||||
let stated: Vec<naming::Quality> = self
|
||||
.episodes()
|
||||
.map(|episode| naming::quality(&episode.node.name))
|
||||
.collect();
|
||||
let most = |holds: &dyn Fn(&naming::Quality) -> bool| {
|
||||
stated.iter().filter(|quality| holds(quality)).count() * 2 > stated.len()
|
||||
};
|
||||
// The height most files have, if most have the same one.
|
||||
let mut heights: HashMap<u32, usize> = HashMap::new();
|
||||
for height in stated.iter().filter_map(|quality| quality.height) {
|
||||
*heights.entry(height).or_default() += 1;
|
||||
}
|
||||
let height = heights
|
||||
.into_iter()
|
||||
.find(|(_, files)| files * 2 > stated.len())
|
||||
.map(|(height, _)| height);
|
||||
naming::Quality {
|
||||
height,
|
||||
hdr: most(&|quality| quality.hdr),
|
||||
ten_bit: most(&|quality| quality.ten_bit),
|
||||
dual_audio: most(&|quality| quality.dual_audio),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn watched_count(&self) -> usize {
|
||||
self.episodes().filter(|e| e.is_watched()).count()
|
||||
}
|
||||
@@ -189,10 +218,44 @@ impl Title {
|
||||
.max_by_key(|episode| episode.last_played())
|
||||
}
|
||||
|
||||
/// What a click on the poster should play: whatever is part-watched, else
|
||||
/// the first unwatched episode, else the first episode.
|
||||
/// What Continue watching offers for this title: the part-watched episode,
|
||||
/// or the one after the episode finished last.
|
||||
///
|
||||
/// Read off whichever episode was touched last. Finishing an episode is
|
||||
/// the most ordinary moment to want the next, and a shelf that dropped the
|
||||
/// show right then was missing its point. An episode reset to the start —
|
||||
/// which is what leaving Continue watching does — says the viewer is done
|
||||
/// with it, so the title is left out. One opened for a few seconds says
|
||||
/// nothing either way and is passed over.
|
||||
pub fn continue_with(&self) -> Option<&Episode> {
|
||||
let latest = self
|
||||
.episodes()
|
||||
.filter(|episode| {
|
||||
episode.watch.is_some_and(|watch| {
|
||||
watch.position_secs == 0.0
|
||||
|| watch.position_secs >= RESUME_FLOOR_SECS
|
||||
|| episode.is_watched()
|
||||
})
|
||||
})
|
||||
.max_by_key(|episode| episode.last_played())?;
|
||||
if latest.resume_at().is_some() {
|
||||
return Some(latest);
|
||||
}
|
||||
if !latest.is_watched() {
|
||||
return None;
|
||||
}
|
||||
let at = self.position_of(&latest.node.share_id, &latest.node.link_id)?;
|
||||
self.episodes()
|
||||
.skip(at + 1)
|
||||
.find(|episode| !episode.is_watched())
|
||||
}
|
||||
|
||||
/// What a click on the poster should play: what Continue watching offers,
|
||||
/// else whatever is part-watched, else the first unwatched episode, else
|
||||
/// the first episode.
|
||||
pub fn next_up(&self) -> Option<&Episode> {
|
||||
self.resume()
|
||||
self.continue_with()
|
||||
.or_else(|| self.resume())
|
||||
.or_else(|| self.episodes().find(|episode| !episode.is_watched()))
|
||||
.or_else(|| self.episodes().next())
|
||||
}
|
||||
@@ -357,13 +420,13 @@ impl Library {
|
||||
self.titles.iter().find(|title| title.key == key)
|
||||
}
|
||||
|
||||
/// Titles with something part-watched, most recent first. The top row of
|
||||
/// the library.
|
||||
/// Titles with something to go on with — see [`Title::continue_with`] —
|
||||
/// most recent first. The top row of the library.
|
||||
pub fn continue_watching(&self) -> Vec<&Title> {
|
||||
let mut titles: Vec<&Title> = self
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|title| title.resume().is_some())
|
||||
.filter(|title| title.continue_with().is_some())
|
||||
.collect();
|
||||
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
|
||||
titles
|
||||
@@ -533,6 +596,36 @@ mod tests {
|
||||
assert_eq!(title.preceding("s1", "a"), None);
|
||||
}
|
||||
|
||||
/// One odd file out — a 720p special, a release with no dub — does not
|
||||
/// change what the title as a whole is said to be.
|
||||
#[test]
|
||||
fn a_title_states_the_quality_most_of_its_files_have() {
|
||||
let library = Library::build(
|
||||
vec![
|
||||
node(
|
||||
"s1",
|
||||
"a",
|
||||
"Show - S01E01 [1080p][Dual-Audio].mkv",
|
||||
Some(1),
|
||||
Some(1),
|
||||
),
|
||||
node(
|
||||
"s1",
|
||||
"b",
|
||||
"Show - S01E02 [1080p][Dual-Audio].mkv",
|
||||
Some(1),
|
||||
Some(2),
|
||||
),
|
||||
node("s1", "c", "Show - S01E03 [720p].mkv", Some(1), Some(3)),
|
||||
],
|
||||
&HashMap::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
library.titles[0].quality().badges(),
|
||||
["1080p", "Dual audio"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_another_share_is_not_this_titles_neighbour() {
|
||||
let library = Library::build(
|
||||
@@ -688,6 +781,64 @@ mod tests {
|
||||
assert_eq!(library.continue_watching().len(), 1);
|
||||
}
|
||||
|
||||
fn three_episodes() -> Vec<CatalogNode> {
|
||||
vec![
|
||||
node("s", "1", "Show - S01E01", Some(1), Some(1)),
|
||||
node("s", "2", "Show - S01E02", Some(1), Some(2)),
|
||||
node("s", "3", "Show - S01E03", Some(1), Some(3)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_episode_puts_the_next_one_in_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
let next = library.titles[0].continue_with().unwrap();
|
||||
assert_eq!(next.node.link_id, "2");
|
||||
assert_eq!(next.resume_at(), None);
|
||||
assert_eq!(library.continue_watching().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_last_episode_leaves_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
for link in ["1", "2", "3"] {
|
||||
states.insert(("s".into(), link.into()), watch(1440.0, 1440.0, true, 10));
|
||||
}
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert!(library.continue_watching().is_empty());
|
||||
}
|
||||
|
||||
/// Leaving Continue watching resets the episode to the start. Offering the
|
||||
/// one after the last finished episode instead would bring the title
|
||||
/// straight back.
|
||||
#[test]
|
||||
fn an_episode_reset_to_the_start_keeps_its_title_out_of_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
|
||||
states.insert(("s".into(), "2".into()), watch(0.0, 1440.0, false, 20));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert!(library.continue_watching().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_few_seconds_of_the_next_episode_do_not_hide_the_one_part_watched() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(700.0, 1440.0, false, 10));
|
||||
states.insert(("s".into(), "2".into()), watch(4.0, 1440.0, false, 20));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_episode_after_the_finished_one_is_offered_past_an_older_unwatched_one() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "2".into()), watch(1440.0, 1440.0, true, 10));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||
let nodes = vec![
|
||||
|
||||
@@ -39,15 +39,23 @@ pub enum ProviderId {
|
||||
AniList,
|
||||
/// The Movie Database. Film and television, needs a free API key.
|
||||
Tmdb,
|
||||
/// TVmaze. Television, no API key. Never chosen on its own: it answers
|
||||
/// for what AniList has not got — see `pstr_meta::service`.
|
||||
TvMaze,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb];
|
||||
/// Every provider a stored row can name.
|
||||
pub const ALL: [Self; 3] = [Self::AniList, Self::Tmdb, Self::TvMaze];
|
||||
|
||||
/// The ones a viewer picks between in the settings.
|
||||
pub const CHOOSABLE: [Self; 2] = [Self::AniList, Self::Tmdb];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AniList => "anilist",
|
||||
Self::Tmdb => "tmdb",
|
||||
Self::TvMaze => "tvmaze",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +64,7 @@ impl ProviderId {
|
||||
match self {
|
||||
Self::AniList => "AniList",
|
||||
Self::Tmdb => "TMDB",
|
||||
Self::TvMaze => "TVmaze",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +73,11 @@ impl ProviderId {
|
||||
match self {
|
||||
Self::AniList => {
|
||||
"Anime, with no API key and no account. Episode names and wide art come from \
|
||||
ani.zip, which is sent the matched ids."
|
||||
ani.zip, which is sent the matched ids. Shows AniList does not have are \
|
||||
looked up on TVmaze."
|
||||
}
|
||||
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
|
||||
Self::TvMaze => "Television, with no API key and no account.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +115,11 @@ pub struct TitleMetadata {
|
||||
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
|
||||
/// grid cannot simply prefer one shape.
|
||||
pub backdrop_url: Option<String>,
|
||||
/// The title's name as its own lettering: a transparent PNG, drawn over
|
||||
/// wide art in place of the name in type. Found by enrichment, like the
|
||||
/// backdrop, and padded by its source — see `pstr-app`'s `trim_alpha`.
|
||||
#[serde(default)]
|
||||
pub logo_url: Option<String>,
|
||||
/// Out of 10, however the provider scores.
|
||||
pub rating: Option<f32>,
|
||||
pub genres: Vec<String>,
|
||||
@@ -161,6 +177,20 @@ pub struct TitleDetails {
|
||||
/// side stories, recaps, retellings. What the library groups a franchise
|
||||
/// by; see `crate::franchise`.
|
||||
pub related: Vec<String>,
|
||||
/// Minutes: an episode's length for a series, the whole running time for
|
||||
/// a film.
|
||||
pub runtime: Option<u32>,
|
||||
/// The main characters and then the supporting ones, each with who voices
|
||||
/// them in the original language.
|
||||
pub cast: Vec<CastMember>,
|
||||
/// Where the provider says its trailer is.
|
||||
pub trailer: Option<Trailer>,
|
||||
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
|
||||
/// older one lacks a field a newer build asks for, and is fetched again
|
||||
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
|
||||
/// migration: until the refetch lands, the old details still group the
|
||||
/// library and fill its shelves.
|
||||
pub revision: u32,
|
||||
/// The entries enrichment walked through to find the title's episodes —
|
||||
/// its own sequels, by provider id. Stored apart from the rest, because
|
||||
/// it is enrichment's answer rather than the match's.
|
||||
@@ -168,7 +198,63 @@ pub struct TitleDetails {
|
||||
pub chain: Vec<String>,
|
||||
}
|
||||
|
||||
/// One character, and who voices them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CastMember {
|
||||
pub character: String,
|
||||
/// The character's portrait.
|
||||
pub character_image: Option<String>,
|
||||
/// `MAIN`, `SUPPORTING` or `BACKGROUND`.
|
||||
pub role: Option<String>,
|
||||
/// The voice actor in the original language.
|
||||
pub actor: Option<String>,
|
||||
pub actor_image: Option<String>,
|
||||
}
|
||||
|
||||
/// A trailer, as the provider names it: a site and that site's id for it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Trailer {
|
||||
/// `youtube` or `dailymotion`.
|
||||
pub site: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl Trailer {
|
||||
/// The trailer's page, to open in the browser. The app itself never
|
||||
/// requests it. `None` for a site this build does not know.
|
||||
pub fn url(&self) -> Option<String> {
|
||||
let id: String = self
|
||||
.id
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||
.collect();
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.site.to_ascii_lowercase().as_str() {
|
||||
"youtube" => Some(format!("https://www.youtube.com/watch?v={id}")),
|
||||
"dailymotion" => Some(format!("https://www.dailymotion.com/video/{id}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TitleDetails {
|
||||
/// What a provider that fills these in today writes into
|
||||
/// [`TitleDetails::revision`]. Raise it with a field that existing
|
||||
/// records should be fetched again for. `1` added
|
||||
/// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
|
||||
/// [`TitleDetails::trailer`].
|
||||
pub const REVISION: u32 = 2;
|
||||
|
||||
/// Whether these were written by this build's [`TitleDetails::REVISION`]
|
||||
/// or a later one.
|
||||
pub fn is_current(&self) -> bool {
|
||||
self.revision >= Self::REVISION
|
||||
}
|
||||
|
||||
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
|
||||
pub fn format_label(&self) -> Option<&'static str> {
|
||||
Some(match self.format.as_deref()? {
|
||||
@@ -201,6 +287,20 @@ impl TitleDetails {
|
||||
self.status.as_deref() == Some("RELEASING")
|
||||
}
|
||||
|
||||
/// [`TitleDetails::status`] said for a person: `Airing`, `Finished`,
|
||||
/// `Upcoming`, `On hiatus` or `Cancelled`. `None` for a status nobody
|
||||
/// filled in, or one this build does not know.
|
||||
pub fn status_label(&self) -> Option<&'static str> {
|
||||
Some(match self.status.as_deref()? {
|
||||
"RELEASING" => "Airing",
|
||||
"FINISHED" => "Finished",
|
||||
"NOT_YET_RELEASED" => "Upcoming",
|
||||
"HIATUS" => "On hiatus",
|
||||
"CANCELLED" => "Cancelled",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The next episode and when it airs, when that is still after `now`.
|
||||
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
|
||||
let at = self.next_airing_at.filter(|at| *at > now)?;
|
||||
@@ -317,6 +417,14 @@ pub struct EpisodeMetadata {
|
||||
/// sorted on, and a provider that answers `2009` should not be a parse
|
||||
/// error.
|
||||
pub air_date: Option<String>,
|
||||
/// Minutes.
|
||||
#[serde(default)]
|
||||
pub runtime: Option<u32>,
|
||||
/// The provider entry's id on MyAnimeList, which is what AniSkip knows
|
||||
/// openings and endings by. With `entry_number`, it names this episode
|
||||
/// there.
|
||||
#[serde(default)]
|
||||
pub mal_id: Option<u32>,
|
||||
}
|
||||
|
||||
/// What enriching a matched title found: its episodes, and art the search
|
||||
@@ -331,6 +439,9 @@ pub struct Enrichment {
|
||||
/// A 16:9 picture of the whole title, when one was found. `None` leaves
|
||||
/// whatever backdrop the match already had.
|
||||
pub backdrop_url: Option<String>,
|
||||
/// The title's logo, when one was found. `None` leaves whatever the match
|
||||
/// already had, as with the backdrop.
|
||||
pub logo_url: Option<String>,
|
||||
/// Every provider entry the episodes were taken from, the match's own
|
||||
/// included. See [`TitleDetails::chain`].
|
||||
pub chain: Vec<String>,
|
||||
@@ -496,8 +607,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
|
||||
|
||||
impl MetadataRecord {
|
||||
/// Whether this answer is still worth using rather than asking again.
|
||||
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool {
|
||||
if self.provider != provider {
|
||||
///
|
||||
/// `chain` is the providers asked, in order. A match from any of them
|
||||
/// stands. A miss is stored under the last one asked, and only stands for
|
||||
/// a chain that ends there: one an earlier provider stored was never
|
||||
/// followed by the providers after it.
|
||||
pub fn is_fresh(&self, now: i64, chain: &[ProviderId]) -> bool {
|
||||
if !chain.contains(&self.provider) {
|
||||
return false;
|
||||
}
|
||||
// A hand-picked match does not go stale: nothing a re-ask could learn
|
||||
@@ -507,8 +623,10 @@ impl MetadataRecord {
|
||||
}
|
||||
let ttl = if self.metadata.is_some() {
|
||||
MATCH_TTL_SECS
|
||||
} else {
|
||||
} else if chain.last() == Some(&self.provider) {
|
||||
MISS_TTL_SECS
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
now.saturating_sub(self.fetched_at) < ttl
|
||||
}
|
||||
@@ -539,6 +657,57 @@ impl Default for MetadataConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_trailer_opens_on_its_own_site_and_an_unknown_site_or_odd_id_opens_nothing() {
|
||||
let trailer = |site: &str, id: &str| Trailer {
|
||||
site: site.into(),
|
||||
id: id.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
trailer("youtube", "tR8YH0G67Rk").url().as_deref(),
|
||||
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
|
||||
);
|
||||
assert_eq!(
|
||||
trailer("dailymotion", "x7tgad0").url().as_deref(),
|
||||
Some("https://www.dailymotion.com/video/x7tgad0")
|
||||
);
|
||||
assert_eq!(trailer("vimeo", "1").url(), None);
|
||||
// Whatever the provider sent, only an id's own characters reach the
|
||||
// address.
|
||||
assert_eq!(
|
||||
trailer("youtube", "a&b=c").url().as_deref(),
|
||||
Some("https://www.youtube.com/watch?v=abc")
|
||||
);
|
||||
assert_eq!(trailer("youtube", "&?").url(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_stored_before_the_cast_existed_still_read() {
|
||||
let old = r#"{"studios":["MAPPA"],"revision":1}"#;
|
||||
let details: TitleDetails = serde_json::from_str(old).unwrap();
|
||||
assert!(details.cast.is_empty());
|
||||
assert_eq!(details.trailer, None);
|
||||
assert!(!details.is_current(), "fetched again for the cast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
|
||||
let status = |value: &str| {
|
||||
TitleDetails {
|
||||
status: Some(value.to_owned()),
|
||||
..TitleDetails::default()
|
||||
}
|
||||
.status_label()
|
||||
};
|
||||
assert_eq!(status("RELEASING"), Some("Airing"));
|
||||
assert_eq!(status("FINISHED"), Some("Finished"));
|
||||
assert_eq!(status("NOT_YET_RELEASED"), Some("Upcoming"));
|
||||
assert_eq!(status("HIATUS"), Some("On hiatus"));
|
||||
assert_eq!(status("CANCELLED"), Some("Cancelled"));
|
||||
assert_eq!(status("SOMETHING_NEW"), None);
|
||||
assert_eq!(TitleDetails::default().status_label(), None);
|
||||
}
|
||||
|
||||
fn metadata() -> TitleMetadata {
|
||||
TitleMetadata {
|
||||
provider: ProviderId::AniList,
|
||||
@@ -550,6 +719,7 @@ mod tests {
|
||||
kind: TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: Some(26),
|
||||
@@ -569,6 +739,8 @@ mod tests {
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
runtime: None,
|
||||
mal_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,13 +924,13 @@ mod tests {
|
||||
fetched_at: now - MISS_TTL_SECS - 1,
|
||||
manual: false,
|
||||
};
|
||||
assert!(!miss.is_fresh(now, ProviderId::AniList));
|
||||
assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
|
||||
|
||||
let matched = MetadataRecord {
|
||||
metadata: Some(metadata()),
|
||||
..miss.clone()
|
||||
};
|
||||
assert!(matched.is_fresh(now, ProviderId::AniList));
|
||||
assert!(matched.is_fresh(now, &[ProviderId::AniList]));
|
||||
}
|
||||
|
||||
/// What the viewer picked by hand outlives every TTL: it is not an answer
|
||||
@@ -772,9 +944,9 @@ mod tests {
|
||||
fetched_at: 0,
|
||||
manual: true,
|
||||
};
|
||||
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList));
|
||||
assert!(record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::AniList]));
|
||||
// But it is still one provider's id, and it means nothing to the other.
|
||||
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb));
|
||||
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::Tmdb]));
|
||||
}
|
||||
|
||||
/// Switching provider must re-ask rather than trust the other one's answer —
|
||||
@@ -788,6 +960,32 @@ mod tests {
|
||||
fetched_at: 1_000_000_000,
|
||||
manual: false,
|
||||
};
|
||||
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
|
||||
assert!(!record.is_fresh(1_000_000_000, &[ProviderId::Tmdb]));
|
||||
}
|
||||
|
||||
/// AniList's miss was stored before TVmaze followed it, and so says
|
||||
/// nothing about what TVmaze has; TVmaze's miss, last in the chain, does.
|
||||
/// A match from either stands.
|
||||
#[test]
|
||||
fn a_miss_only_stands_when_the_last_provider_asked_stored_it() {
|
||||
let chain = [ProviderId::AniList, ProviderId::TvMaze];
|
||||
let now = 1_000_000_000;
|
||||
let miss = |provider| MetadataRecord {
|
||||
title_key: "game of thrones".into(),
|
||||
provider,
|
||||
metadata: None,
|
||||
fetched_at: now,
|
||||
manual: false,
|
||||
};
|
||||
assert!(!miss(ProviderId::AniList).is_fresh(now, &chain));
|
||||
assert!(miss(ProviderId::TvMaze).is_fresh(now, &chain));
|
||||
assert!(miss(ProviderId::AniList).is_fresh(now, &[ProviderId::AniList]));
|
||||
|
||||
let found = |provider| MetadataRecord {
|
||||
metadata: Some(metadata()),
|
||||
..miss(provider)
|
||||
};
|
||||
assert!(found(ProviderId::AniList).is_fresh(now, &chain));
|
||||
assert!(found(ProviderId::TvMaze).is_fresh(now, &chain));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! My List: the titles the viewer put aside to watch, kept in
|
||||
//! `my-list.json` beside the other settings.
|
||||
//!
|
||||
//! Keyed by title key, like a hand-picked match, so a title keeps its place on
|
||||
//! the list across a recrawl and on every device that has the same share. A
|
||||
//! title that leaves the library stays on the list, unseen, and is back on it
|
||||
//! when the share brings it back.
|
||||
//!
|
||||
//! It travels with the profile, one `list/<title key>` register per title
|
||||
//! ([`crate::profile`]): adding a title on the phone puts it on the laptop's
|
||||
//! list, and taking it off on one takes it off on all of them.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::Result;
|
||||
|
||||
/// The titles on the list, by key, with when each was put there in Unix
|
||||
/// seconds.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MyList {
|
||||
#[serde(default)]
|
||||
titles: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
impl MyList {
|
||||
pub fn contains(&self, key: &str) -> bool {
|
||||
self.titles.contains_key(key)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.titles.is_empty()
|
||||
}
|
||||
|
||||
/// When `key` was put on the list, if it is on it.
|
||||
pub fn added_at(&self, key: &str) -> Option<i64> {
|
||||
self.titles.get(key).copied()
|
||||
}
|
||||
|
||||
/// Put `key` on the list at `at`. One already there keeps its place.
|
||||
pub fn add(&mut self, key: &str, at: i64) {
|
||||
self.titles.entry(key.to_owned()).or_insert(at);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) {
|
||||
self.titles.remove(key);
|
||||
}
|
||||
|
||||
/// Put `key` on the list or take it off, saying which.
|
||||
pub fn toggle(&mut self, key: &str, at: i64) -> bool {
|
||||
if self.contains(key) {
|
||||
self.remove(key);
|
||||
false
|
||||
} else {
|
||||
self.add(key, at);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Every key with when it was added, in key order.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&str, i64)> {
|
||||
self.titles.iter().map(|(key, at)| (key.as_str(), *at))
|
||||
}
|
||||
|
||||
/// The keys, most recently added first: the order the row is in.
|
||||
pub fn newest_first(&self) -> Vec<&str> {
|
||||
let mut keys: Vec<(&str, i64)> = self.entries().collect();
|
||||
keys.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
|
||||
keys.into_iter().map(|(key, _)| key).collect()
|
||||
}
|
||||
|
||||
/// Set `key`'s entry as another device had it: on the list since `at`,
|
||||
/// or, with `None`, off it.
|
||||
pub(crate) fn set(&mut self, key: &str, at: Option<i64>) {
|
||||
match at {
|
||||
Some(at) => {
|
||||
self.titles.insert(key.to_owned(), at);
|
||||
}
|
||||
None => self.remove(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn file(dirs: &AppDirs) -> PathBuf {
|
||||
dirs.config.join("my-list.json")
|
||||
}
|
||||
|
||||
/// The list, empty when there is no file yet.
|
||||
pub fn load(dirs: &AppDirs) -> Result<MyList> {
|
||||
Ok(read_json::<MyList>(&file(dirs))?.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn save(dirs: &AppDirs, list: &MyList) -> Result<()> {
|
||||
write_json(&file(dirs), list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_title_added_twice_keeps_the_time_it_was_first_added() {
|
||||
let mut list = MyList::default();
|
||||
list.add("frieren", 10);
|
||||
list.add("frieren", 20);
|
||||
assert_eq!(list.added_at("frieren"), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_puts_a_title_on_the_list_and_takes_it_off_again() {
|
||||
let mut list = MyList::default();
|
||||
assert!(list.toggle("frieren", 10));
|
||||
assert!(list.contains("frieren"));
|
||||
assert!(!list.toggle("frieren", 20));
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_shows_the_most_recently_added_title_first() {
|
||||
let mut list = MyList::default();
|
||||
list.add("a", 30);
|
||||
list.add("b", 10);
|
||||
list.add("c", 20);
|
||||
assert_eq!(list.newest_first(), ["a", "c", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_survives_a_save_and_a_load() {
|
||||
let root = std::env::temp_dir().join(format!("pstr-mylist-{}", std::process::id()));
|
||||
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
|
||||
assert!(load(&dirs).unwrap().is_empty());
|
||||
let mut list = MyList::default();
|
||||
list.add("one piece", 5);
|
||||
save(&dirs, &list).unwrap();
|
||||
assert_eq!(load(&dirs).unwrap(), list);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ pub struct ParsedName {
|
||||
/// Release year, from a parenthesised or bracketed four-digit group.
|
||||
pub year: Option<u32>,
|
||||
/// The episode's own name, when the filename states one after the
|
||||
/// numbering (`S01E01-Mother and Children`). Never the quality and codec
|
||||
/// tags that follow numbering in scene releases — see [`episode_title`].
|
||||
/// numbering (`S01E01-Mother and Children`, `S01E01.Winter.Is.Coming.2160p`).
|
||||
/// Never the quality and codec tags that follow numbering in scene
|
||||
/// releases — see [`episode_title`].
|
||||
pub episode_title: Option<String>,
|
||||
}
|
||||
|
||||
@@ -98,17 +99,21 @@ struct NumberingMatch {
|
||||
|
||||
/// The episode's own name out of what followed the numbering.
|
||||
///
|
||||
/// Only taken when a dash introduces it. Scene releases separate their quality,
|
||||
/// source and codec tags with the same character as their title words (`.`), so
|
||||
/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an
|
||||
/// "episode title" of `1080p WEB-DL x265`.
|
||||
/// Taken when a dash introduces it, or from a scene release's dotted words up
|
||||
/// to its first release tag. Scene releases separate their quality, source and
|
||||
/// codec tags with the same character as their title words (`.`), so the tag
|
||||
/// is what ends the name: `Show.S01E01.Winter.Is.Coming.2160p.BluRay` names
|
||||
/// its episode, `Show.Name.S02E05.1080p.WEB-DL.x265` does not, and a dotted
|
||||
/// run that no tag ends is not trusted to be a name at all.
|
||||
fn episode_title(tail: &str) -> Option<String> {
|
||||
let rest = tail.trim_start();
|
||||
let rest = rest
|
||||
let cleaned = match rest
|
||||
.strip_prefix('-')
|
||||
.or_else(|| rest.strip_prefix('\u{2013}'))?;
|
||||
|
||||
let cleaned = clean_title(rest, None);
|
||||
.or_else(|| rest.strip_prefix('\u{2013}'))
|
||||
{
|
||||
Some(rest) => clean_title(rest, None),
|
||||
None => dotted_title(rest.strip_prefix('.')?)?,
|
||||
};
|
||||
let cleaned = cleaned.trim().to_string();
|
||||
if cleaned.is_empty() {
|
||||
None
|
||||
@@ -117,6 +122,110 @@ fn episode_title(tail: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Winter.Is.Coming.2160p.UHD…` → `Winter Is Coming`.
|
||||
fn dotted_title(rest: &str) -> Option<String> {
|
||||
let words: Vec<&str> = rest.split('.').collect();
|
||||
let end = words.iter().position(|word| is_release_tag(word))?;
|
||||
Some(words[..end].join(" "))
|
||||
}
|
||||
|
||||
/// Whether a dotted word is a release's quality, source or codec rather than
|
||||
/// part of a name.
|
||||
fn is_release_tag(word: &str) -> bool {
|
||||
const TAGS: &[&str] = &[
|
||||
"4k", "uhd", "bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "web", "web-dl",
|
||||
"webdl", "webrip", "hdtv", "dvdrip", "hdrip", "x264", "x265", "h264", "h265", "hevc",
|
||||
"avc", "hdr", "hdr10", "dv", "10bit", "10-bit", "proper", "repack", "amzn", "nf", "dsnp",
|
||||
"hmax", "atvp",
|
||||
];
|
||||
let word = word.to_ascii_lowercase();
|
||||
let resolution = word.strip_suffix('p').is_some_and(|digits| {
|
||||
(3..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
|
||||
});
|
||||
resolution || TAGS.contains(&word.as_str())
|
||||
}
|
||||
|
||||
/// What a release name says about its picture and sound.
|
||||
///
|
||||
/// Read for the facts line of a title's page, and nothing else: no release
|
||||
/// group's name is ever taken, and a name that says nothing yields the default.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Quality {
|
||||
/// Lines of picture: `2160` for `2160p`, `4K` or `UHD`.
|
||||
pub height: Option<u32>,
|
||||
/// `HDR`, `HDR10`, `HDR10+` or Dolby Vision.
|
||||
pub hdr: bool,
|
||||
pub ten_bit: bool,
|
||||
/// Two audio languages, as anime releases mark a dub beside the original.
|
||||
pub dual_audio: bool,
|
||||
}
|
||||
|
||||
impl Quality {
|
||||
/// The short labels to show, in a fixed order: `4K`, `HDR`, `10-bit`,
|
||||
/// `Dual audio`.
|
||||
pub fn badges(&self) -> Vec<&'static str> {
|
||||
let resolution = self.height.map(|height| match height {
|
||||
2160.. => "4K",
|
||||
1440.. => "1440p",
|
||||
1080.. => "1080p",
|
||||
720.. => "720p",
|
||||
_ => "SD",
|
||||
});
|
||||
resolution
|
||||
.into_iter()
|
||||
.chain(self.hdr.then_some("HDR"))
|
||||
.chain(self.ten_bit.then_some("10-bit"))
|
||||
.chain(self.dual_audio.then_some("Dual audio"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Quality`] a file name states.
|
||||
///
|
||||
/// Words are taken apart on everything but letters, digits and `+`, so
|
||||
/// `[1080p][10-bit]`, `.10bit.` and `(BD 1080p HEVC 10bit Dual Audio)` read
|
||||
/// the same.
|
||||
pub fn quality(file_name: &str) -> Quality {
|
||||
let lower = strip_extension(file_name).to_ascii_lowercase();
|
||||
let words: Vec<&str> = lower
|
||||
.split(|c: char| !c.is_ascii_alphanumeric() && c != '+')
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect();
|
||||
let mut quality = Quality::default();
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let next = words.get(index + 1).copied();
|
||||
let height = match *word {
|
||||
"4k" | "uhd" => Some(2160),
|
||||
_ => resolution(word),
|
||||
};
|
||||
if let Some(height) = height {
|
||||
quality.height = quality.height.max(Some(height));
|
||||
}
|
||||
match (*word, next) {
|
||||
("hdr" | "hdr10" | "hdr10+" | "dv" | "dovi", _) | ("dolby", Some("vision")) => {
|
||||
quality.hdr = true;
|
||||
}
|
||||
("10bit" | "10bits" | "hi10" | "hi10p", _) | ("10", Some("bit" | "bits")) => {
|
||||
quality.ten_bit = true;
|
||||
}
|
||||
("dualaudio", _) | ("dual" | "multi", Some("audio")) => quality.dual_audio = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
quality
|
||||
}
|
||||
|
||||
/// `1080` out of `1080p`, and out of `bd1080p`, which some groups write.
|
||||
fn resolution(word: &str) -> Option<u32> {
|
||||
let digits = word
|
||||
.strip_suffix('p')?
|
||||
.trim_start_matches(|c: char| c.is_ascii_alphabetic());
|
||||
if !(3..=4).contains(&digits.len()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
/// Drop a trailing extension, but only one that looks like an extension.
|
||||
///
|
||||
/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the
|
||||
@@ -539,6 +648,9 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
|
||||
if is_token_char(tail) && tail != Some(b'v') {
|
||||
continue;
|
||||
}
|
||||
if is_decimal_tail(text, after) {
|
||||
continue;
|
||||
}
|
||||
found = Some(NumberingMatch {
|
||||
numbering: Numbering {
|
||||
season: None,
|
||||
@@ -551,6 +663,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
|
||||
found
|
||||
}
|
||||
|
||||
/// Whether the number that ended at `at` goes on as a decimal: `.11` in
|
||||
/// `Evangelion - 1.11 You Are (Not) Alone`, `.0` in `3.0+1.01`.
|
||||
///
|
||||
/// That is a version of a film, not episode 1 of anything, and reading it as
|
||||
/// one turns a folder of four films into a series of episodes 1, 2, 3 and 3.
|
||||
/// One or two digits only: `- 05.1080p` is episode 5 and a resolution.
|
||||
fn is_decimal_tail(text: &str, at: usize) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.get(at) != Some(&b'.') {
|
||||
return false;
|
||||
}
|
||||
let digits = bytes[at + 1..]
|
||||
.iter()
|
||||
.take_while(|byte| byte.is_ascii_digit())
|
||||
.count();
|
||||
(1..=2).contains(&digits) && !is_token_char(bytes.get(at + 1 + digits).copied())
|
||||
}
|
||||
|
||||
/// Step over an `E` / `EP` episode marker and any separator after it.
|
||||
///
|
||||
/// Returns `at` unchanged when there is none, so a bare number and a marked one
|
||||
@@ -630,9 +760,18 @@ fn is_token_start(text: &str, at: usize) -> bool {
|
||||
/// Normalize the title span: separators to spaces, then trim the debris that
|
||||
/// sits between the title and whatever followed it.
|
||||
fn clean_title(raw: &str, year: Option<u32>) -> String {
|
||||
// A dot between two digits is a decimal point, not a separator: `1.11`
|
||||
// is the film's name, `1 11` is two numbers.
|
||||
let bytes = raw.as_bytes();
|
||||
let mut text: String = raw
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
.char_indices()
|
||||
.map(|(at, c)| match c {
|
||||
'.' if at > 0
|
||||
&& bytes[at - 1].is_ascii_digit()
|
||||
&& bytes.get(at + 1).is_some_and(u8::is_ascii_digit) =>
|
||||
{
|
||||
'.'
|
||||
}
|
||||
'.' | '_' => ' ',
|
||||
other => other,
|
||||
})
|
||||
@@ -813,6 +952,15 @@ pub fn is_video_file(name: &str) -> bool {
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -847,6 +995,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **What the four Rebuild of Evangelion films parsed as.** ` - 1.11` read
|
||||
/// as episode 1, so the folder became a series of episodes 1, 2, 3 and 3.
|
||||
/// A decimal is a version of the film, and the name keeps it.
|
||||
#[test]
|
||||
fn a_decimal_after_the_dash_is_part_of_a_films_name_not_an_episode() {
|
||||
assert_eq!(
|
||||
parsed("Evangelion - 1.11 You Are (Not) Alone.mkv"),
|
||||
("Evangelion - 1.11 You Are Alone".into(), None, None, None)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed("Evangelion - 3.0+1.01 Thrice Upon a Time.mkv"),
|
||||
(
|
||||
"Evangelion - 3.0+1.01 Thrice Upon a Time".into(),
|
||||
None,
|
||||
None,
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A recap numbered between two episodes is not a second copy of the one
|
||||
/// before it.
|
||||
#[test]
|
||||
fn a_half_episode_is_not_the_episode_before_it() {
|
||||
assert_eq!(
|
||||
parsed("[Group] Show Name - 12.5 [1080p].mkv"),
|
||||
("Show Name - 12.5".into(), None, None, None)
|
||||
);
|
||||
}
|
||||
|
||||
/// The decimal rule stops at two digits, so a resolution glued on with a
|
||||
/// dot leaves the episode number alone.
|
||||
#[test]
|
||||
fn a_resolution_after_a_dot_does_not_hide_the_episode() {
|
||||
assert_eq!(
|
||||
parsed("Show Name - 05.1080p.mkv"),
|
||||
("Show Name".into(), None, Some(5), None)
|
||||
);
|
||||
}
|
||||
|
||||
/// A version suffix still names the same episode.
|
||||
#[test]
|
||||
fn a_versioned_fansub_episode_keeps_its_number() {
|
||||
@@ -1033,6 +1221,55 @@ mod tests {
|
||||
assert_eq!(p.title, "Show Name");
|
||||
}
|
||||
|
||||
/// A scene release names its episode in dotted words before its tags, and
|
||||
/// the first tag is where the name ends.
|
||||
#[test]
|
||||
fn a_dotted_episode_name_before_the_release_tags_is_captured() {
|
||||
let p = parse(
|
||||
"Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.10bit.HDR.x265-GRP.mkv",
|
||||
);
|
||||
assert_eq!(p.title, "Game of Thrones");
|
||||
assert_eq!((p.season, p.episode), (Some(1), Some(1)));
|
||||
assert_eq!(p.episode_title.as_deref(), Some("Winter Is Coming"));
|
||||
|
||||
let p = parse("Game.of.Thrones.S06E05.The.Door.1080p.WEB-DL.mkv");
|
||||
assert_eq!(p.episode_title.as_deref(), Some("The Door"));
|
||||
}
|
||||
|
||||
/// Without a tag to end it, a dotted run could be a group's name or
|
||||
/// anything else, and is not taken.
|
||||
#[test]
|
||||
fn a_dotted_run_no_tag_ends_is_not_an_episode_name() {
|
||||
assert_eq!(parse("Show.S01E01.Pilot.mkv").episode_title, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scene_release_states_4k_hdr_and_10_bit() {
|
||||
let stated =
|
||||
quality("Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.x265.10bit.HDR.mkv");
|
||||
assert_eq!(stated.badges(), ["4K", "HDR", "10-bit"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fansub_release_states_its_tags_in_brackets() {
|
||||
let bracketed =
|
||||
quality("[Group] Show - S01E01 [1080p][BD][x265][10-bit][Dual-Audio][1A2B3C4D].mkv");
|
||||
assert_eq!(bracketed.badges(), ["1080p", "10-bit", "Dual audio"]);
|
||||
let spaced = quality("Film (BD 1080p HEVC 10bit Dual Audio).mkv");
|
||||
assert_eq!(spaced.badges(), ["1080p", "10-bit", "Dual audio"]);
|
||||
assert_eq!(
|
||||
quality("[Group] Show - 01 [BD1080p].mkv").height,
|
||||
Some(1080)
|
||||
);
|
||||
}
|
||||
|
||||
/// Most of a real library: a group, a number and nothing else to say.
|
||||
#[test]
|
||||
fn a_name_with_no_tags_states_nothing() {
|
||||
assert_eq!(quality("[Group] Bleach - S17E01.mkv"), Quality::default());
|
||||
assert!(Quality::default().badges().is_empty());
|
||||
}
|
||||
|
||||
/// A library that names its files with nothing but the episode marker.
|
||||
#[test]
|
||||
fn a_bare_episode_marker_is_read_as_an_episode() {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! What arrived since the viewer last looked: the **New** and **3 new**
|
||||
//! badges, the "New episodes" and "Recently added" rows, and the countdown on
|
||||
//! an airing show's tile.
|
||||
//!
|
||||
//! All of it is read off [`crate::catalog::Catalog::first_seen`], the time a
|
||||
//! crawl first found each file. A file the first crawl of its share found is
|
||||
//! not new, however recent that crawl: it was there before this device looked,
|
||||
//! and a share added today would otherwise badge its whole library.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::library::{Episode, Title, TitleKind};
|
||||
use crate::shelves::Shelf;
|
||||
|
||||
/// How long a title that arrived stays **New**, unless it is started first.
|
||||
pub const NEW_FOR: i64 = 14 * 86_400;
|
||||
|
||||
/// What a tile says about what arrived.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Badge {
|
||||
/// A title that arrived in the last [`NEW_FOR`] and was never played.
|
||||
New,
|
||||
/// A series being watched, with this many unwatched episodes that arrived
|
||||
/// after it was last played.
|
||||
Episodes(usize),
|
||||
}
|
||||
|
||||
impl Badge {
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Self::New => "New".to_owned(),
|
||||
Self::Episodes(count) => format!("{count} new"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When each file arrived, read against when its share was first crawled.
|
||||
pub struct Arrivals<'a> {
|
||||
first_seen: &'a HashMap<(String, String), i64>,
|
||||
/// The earliest sighting in each share: its first crawl.
|
||||
first_crawl: HashMap<&'a str, i64>,
|
||||
}
|
||||
|
||||
impl<'a> Arrivals<'a> {
|
||||
pub fn new(first_seen: &'a HashMap<(String, String), i64>) -> Self {
|
||||
let mut first_crawl: HashMap<&str, i64> = HashMap::new();
|
||||
for ((share, _), seen) in first_seen {
|
||||
first_crawl
|
||||
.entry(share.as_str())
|
||||
.and_modify(|first| *first = (*first).min(*seen))
|
||||
.or_insert(*seen);
|
||||
}
|
||||
Self {
|
||||
first_seen,
|
||||
first_crawl,
|
||||
}
|
||||
}
|
||||
|
||||
/// When `episode`'s file arrived, if a crawl after its share's first one
|
||||
/// found it.
|
||||
pub fn arrived(&self, episode: &Episode) -> Option<i64> {
|
||||
let node = &episode.node;
|
||||
let seen = *self
|
||||
.first_seen
|
||||
.get(&(node.share_id.clone(), node.link_id.clone()))?;
|
||||
let first = self.first_crawl.get(node.share_id.as_str())?;
|
||||
(seen > *first).then_some(seen)
|
||||
}
|
||||
|
||||
/// The badge for `title`, if it has one.
|
||||
pub fn badge(&self, title: &Title, now: i64) -> Option<Badge> {
|
||||
let played = title.last_played();
|
||||
if played == 0 {
|
||||
// New only if every file of it arrived: a show the first crawl
|
||||
// found is not new because a crawl since added an episode.
|
||||
let arrived: Option<Vec<i64>> = title
|
||||
.episodes()
|
||||
.map(|episode| self.arrived(episode))
|
||||
.collect();
|
||||
let first = arrived?.into_iter().min()?;
|
||||
return (now - first < NEW_FOR).then_some(Badge::New);
|
||||
}
|
||||
if title.kind != TitleKind::Series {
|
||||
return None;
|
||||
}
|
||||
let count = title
|
||||
.episodes()
|
||||
.filter(|episode| !episode.is_watched())
|
||||
.filter(|episode| self.arrived(episode).is_some_and(|at| at > played))
|
||||
.count();
|
||||
(count > 0).then_some(Badge::Episodes(count))
|
||||
}
|
||||
|
||||
/// When `title` last gained a file that counts as arrived.
|
||||
fn newest(&self, title: &Title) -> Option<i64> {
|
||||
title
|
||||
.episodes()
|
||||
.filter_map(|episode| self.arrived(episode))
|
||||
.max()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every badge in `titles`, by key.
|
||||
pub fn badges(
|
||||
titles: &[Title],
|
||||
first_seen: &HashMap<(String, String), i64>,
|
||||
now: i64,
|
||||
) -> HashMap<String, Badge> {
|
||||
let arrivals = Arrivals::new(first_seen);
|
||||
titles
|
||||
.iter()
|
||||
.filter_map(|title| Some((title.key.clone(), arrivals.badge(title, now)?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The two rows of what arrived, newest arrival first. `None` for a row with
|
||||
/// nothing in it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Rows {
|
||||
/// "New episodes": the shows being watched that gained episodes.
|
||||
pub episodes: Option<Shelf>,
|
||||
/// "Recently added": the titles that are [`Badge::New`].
|
||||
pub added: Option<Shelf>,
|
||||
}
|
||||
|
||||
pub fn rows(titles: &[Title], first_seen: &HashMap<(String, String), i64>, now: i64) -> Rows {
|
||||
let arrivals = Arrivals::new(first_seen);
|
||||
let mut episodes: Vec<(&Title, i64)> = Vec::new();
|
||||
let mut added: Vec<(&Title, i64)> = Vec::new();
|
||||
for title in titles {
|
||||
let (Some(badge), Some(newest)) = (arrivals.badge(title, now), arrivals.newest(title))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match badge {
|
||||
Badge::Episodes(_) => episodes.push((title, newest)),
|
||||
Badge::New => added.push((title, newest)),
|
||||
}
|
||||
}
|
||||
let shelf = |label: &str, mut row: Vec<(&Title, i64)>| {
|
||||
row.sort_by_key(|(_, newest)| std::cmp::Reverse(*newest));
|
||||
(!row.is_empty()).then(|| Shelf {
|
||||
label: label.to_owned(),
|
||||
keys: row
|
||||
.into_iter()
|
||||
.map(|(title, _)| title.key.clone())
|
||||
.collect(),
|
||||
})
|
||||
};
|
||||
Rows {
|
||||
episodes: shelf("New episodes", episodes),
|
||||
added: shelf("Recently added", added),
|
||||
}
|
||||
}
|
||||
|
||||
/// What an airing show's tile says about its next episode: `Ep 8 · in 2 d`,
|
||||
/// `Ep 8 · in 5 h`, `Ep 8 · soon`.
|
||||
pub fn countdown(episode: u32, at: i64, now: i64) -> String {
|
||||
let left = (at - now).max(0);
|
||||
let when = match left {
|
||||
0..3_600 => "soon".to_owned(),
|
||||
3_600..86_400 => format!("in {} h", left / 3_600),
|
||||
_ => format!("in {} d", left / 86_400),
|
||||
};
|
||||
format!("Ep {episode} · {when}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::catalog::{CatalogNode, WatchState};
|
||||
use crate::library::Library;
|
||||
|
||||
const DAY: i64 = 86_400;
|
||||
|
||||
fn file(share: &str, link: &str, title: &str, episode: u32) -> CatalogNode {
|
||||
CatalogNode {
|
||||
share_id: share.into(),
|
||||
link_id: link.into(),
|
||||
volume_id: "v".into(),
|
||||
parent_link_id: None,
|
||||
name: format!("{title} - {episode:02}.mkv"),
|
||||
is_folder: false,
|
||||
media_type: Some("video/x-matroska".into()),
|
||||
size: Some(1),
|
||||
active_revision_id: Some("r".into()),
|
||||
parsed: crate::naming::parse(&format!("{title} - {episode:02}.mkv")),
|
||||
}
|
||||
}
|
||||
|
||||
fn watched(at: i64) -> WatchState {
|
||||
WatchState {
|
||||
position_secs: 1_400.0,
|
||||
duration_secs: Some(1_440.0),
|
||||
watched: true,
|
||||
updated_at: at,
|
||||
}
|
||||
}
|
||||
|
||||
/// One share whose first crawl at day 1 found Frieren 1–2, and Dandadan 1
|
||||
/// arriving at day 20 next to Frieren 3.
|
||||
fn share(frieren_played: Option<i64>) -> (Library, HashMap<(String, String), i64>) {
|
||||
let files = vec![
|
||||
file("s", "f1", "Frieren", 1),
|
||||
file("s", "f2", "Frieren", 2),
|
||||
file("s", "f3", "Frieren", 3),
|
||||
file("s", "d1", "Dandadan", 1),
|
||||
];
|
||||
let mut watch = HashMap::new();
|
||||
if let Some(at) = frieren_played {
|
||||
watch.insert(("s".to_owned(), "f1".to_owned()), watched(at));
|
||||
watch.insert(("s".to_owned(), "f2".to_owned()), watched(at));
|
||||
}
|
||||
let seen = [("f1", 1), ("f2", 1), ("f3", 20), ("d1", 20)]
|
||||
.into_iter()
|
||||
.map(|(link, day)| (("s".to_owned(), link.to_owned()), day * DAY))
|
||||
.collect();
|
||||
(Library::build(files, &watch), seen)
|
||||
}
|
||||
|
||||
fn named<'a>(library: &'a Library, name: &str) -> &'a Title {
|
||||
library
|
||||
.titles
|
||||
.iter()
|
||||
.find(|title| title.name == name)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_first_crawl_of_a_share_found_is_never_new() {
|
||||
let (library, seen) = share(None);
|
||||
let arrivals = Arrivals::new(&seen);
|
||||
let frieren = named(&library, "Frieren");
|
||||
assert_eq!(arrivals.badge(frieren, 21 * DAY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_that_arrived_is_new_for_two_weeks_unless_it_is_started() {
|
||||
let (library, seen) = share(None);
|
||||
let arrivals = Arrivals::new(&seen);
|
||||
let dandadan = named(&library, "Dandadan");
|
||||
assert_eq!(arrivals.badge(dandadan, 21 * DAY), Some(Badge::New));
|
||||
assert_eq!(arrivals.badge(dandadan, 20 * DAY + NEW_FOR), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_show_being_watched_counts_the_episodes_that_arrived_after_the_last_watch() {
|
||||
let (library, seen) = share(Some(10 * DAY));
|
||||
let arrivals = Arrivals::new(&seen);
|
||||
let frieren = named(&library, "Frieren");
|
||||
assert_eq!(arrivals.badge(frieren, 21 * DAY), Some(Badge::Episodes(1)));
|
||||
assert_eq!(Badge::Episodes(1).label(), "1 new");
|
||||
|
||||
// Watched after episode 3 arrived: nothing is new to the viewer.
|
||||
let (library, seen) = share(Some(25 * DAY));
|
||||
let arrivals = Arrivals::new(&seen);
|
||||
assert_eq!(arrivals.badge(named(&library, "Frieren"), 26 * DAY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rows_hold_new_episodes_and_recent_arrivals_and_leave_out_an_empty_one() {
|
||||
let (library, seen) = share(Some(10 * DAY));
|
||||
let found = rows(&library.titles, &seen, 21 * DAY);
|
||||
let labels: Vec<(&str, Vec<&str>)> = [&found.episodes, &found.added]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|row| {
|
||||
let names = row
|
||||
.keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
library
|
||||
.titles
|
||||
.iter()
|
||||
.find(|title| &title.key == key)
|
||||
.unwrap()
|
||||
.name
|
||||
.as_str()
|
||||
})
|
||||
.collect();
|
||||
(row.label.as_str(), names)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
("New episodes", vec!["Frieren"]),
|
||||
("Recently added", vec!["Dandadan"])
|
||||
]
|
||||
);
|
||||
|
||||
let (library, seen) = share(None);
|
||||
let found = rows(&library.titles, &seen, 21 * DAY);
|
||||
assert!(found.episodes.is_none());
|
||||
assert_eq!(found.added.unwrap().label, "Recently added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_countdown_says_days_then_hours_then_soon() {
|
||||
assert_eq!(countdown(8, 3 * DAY + 100, 0), "Ep 8 · in 3 d");
|
||||
assert_eq!(countdown(8, 5 * 3_600 + 10, 0), "Ep 8 · in 5 h");
|
||||
assert_eq!(countdown(8, 600, 0), "Ep 8 · soon");
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,211 @@ pub struct PlaybackPrefs {
|
||||
/// everything at 1.25, and a speed that reset every episode would be a
|
||||
/// setting nobody could use.
|
||||
pub speed: f64,
|
||||
/// How plain subtitles look. Kept with the rest because it is the same
|
||||
/// kind of choice: made once, for every file after.
|
||||
pub subtitle_style: SubtitleStyle,
|
||||
}
|
||||
|
||||
/// How subtitles are drawn.
|
||||
///
|
||||
/// Every default is mpv's own, so a viewer who never opens the section sees
|
||||
/// what any mpv shows. Plain (SRT) tracks take all of it. A styled (ASS) track
|
||||
/// was typeset by somebody, its signs placed and coloured by hand, and painting
|
||||
/// over that undoes the work — so by default it takes none of this, and moves
|
||||
/// only to stay clear of the player's controls. All of it reaches a styled
|
||||
/// track only with [`Self::restyle_styled`].
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SubtitleStyle {
|
||||
/// Relative to mpv's own size, which is itself relative to the video's
|
||||
/// height — so one setting reads the same on a phone and a television.
|
||||
pub scale: f64,
|
||||
/// A font family name. `None` is mpv's `sans-serif`. A name this device
|
||||
/// does not have falls back the same way, which is what makes it safe to
|
||||
/// sync.
|
||||
pub font: Option<String>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub colour: Colour,
|
||||
pub backdrop: SubtitleBackdrop,
|
||||
pub outline_colour: Colour,
|
||||
/// In mpv's scaled pixels, as `sub-outline-size`.
|
||||
pub outline_size: f64,
|
||||
/// The box's colour, its alpha the box's opacity. Only with
|
||||
/// [`SubtitleBackdrop::Box`].
|
||||
pub box_colour: Colour,
|
||||
pub shadow_colour: Colour,
|
||||
/// How far the shadow falls; 0 is none, which is mpv's default.
|
||||
pub shadow_offset: f64,
|
||||
/// How soft the edges are, 0–20.
|
||||
pub blur: f64,
|
||||
/// Where the line sits, as a percentage of the picture's height from the
|
||||
/// top: 100 is the bottom edge, mpv's usual place.
|
||||
pub position: f64,
|
||||
/// Whether styled (ASS) tracks are drawn in this style too, size and
|
||||
/// position included. Off by default: it turns signs and karaoke into
|
||||
/// dialogue-looking text.
|
||||
pub restyle_styled: bool,
|
||||
}
|
||||
|
||||
impl SubtitleStyle {
|
||||
/// The family a style with no font of its own is set in: mpv's default,
|
||||
/// which fontconfig resolves to the system's sans-serif face.
|
||||
pub const DEFAULT_FONT: &str = "sans-serif";
|
||||
pub const MIN_SCALE: f64 = 0.5;
|
||||
pub const MAX_SCALE: f64 = 2.5;
|
||||
pub const MAX_OUTLINE: f64 = 10.0;
|
||||
pub const MAX_SHADOW: f64 = 10.0;
|
||||
pub const MAX_BLUR: f64 = 20.0;
|
||||
|
||||
/// The same style with every number brought back into range.
|
||||
///
|
||||
/// A hand-edited or synced file can hold anything, and mpv refuses an
|
||||
/// out-of-range value outright — which would cost the rest of the style.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
let defaults = Self::default();
|
||||
let clamp = |value: f64, default: f64, min: f64, max: f64| {
|
||||
if value.is_finite() {
|
||||
value.clamp(min, max)
|
||||
} else {
|
||||
default
|
||||
}
|
||||
};
|
||||
self.scale = clamp(self.scale, defaults.scale, Self::MIN_SCALE, Self::MAX_SCALE);
|
||||
self.outline_size = clamp(
|
||||
self.outline_size,
|
||||
defaults.outline_size,
|
||||
0.0,
|
||||
Self::MAX_OUTLINE,
|
||||
);
|
||||
self.shadow_offset = clamp(
|
||||
self.shadow_offset,
|
||||
defaults.shadow_offset,
|
||||
0.0,
|
||||
Self::MAX_SHADOW,
|
||||
);
|
||||
self.blur = clamp(self.blur, defaults.blur, 0.0, Self::MAX_BLUR);
|
||||
self.position = clamp(self.position, defaults.position, 0.0, 100.0);
|
||||
self.font = self
|
||||
.font
|
||||
.map(|font| font.trim().to_owned())
|
||||
.filter(|font| !font.is_empty());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubtitleStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scale: 1.0,
|
||||
font: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
colour: Colour::rgba(0xFF, 0xFF, 0xFF, 0xFF),
|
||||
backdrop: SubtitleBackdrop::Outline,
|
||||
outline_colour: Colour::rgba(0, 0, 0, 0xFF),
|
||||
outline_size: 1.65,
|
||||
// About seventy percent black.
|
||||
box_colour: Colour::rgba(0, 0, 0, 0xB0),
|
||||
shadow_colour: Colour::rgba(0, 0, 0, 0xAF),
|
||||
shadow_offset: 0.0,
|
||||
blur: 0.0,
|
||||
position: 100.0,
|
||||
restyle_styled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What keeps the text readable against the picture.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SubtitleBackdrop {
|
||||
/// An outline round each letter: mpv's own, and the default.
|
||||
#[default]
|
||||
Outline,
|
||||
/// A box behind each line. Uglier, and the only thing that holds up on
|
||||
/// snow, sky and white walls.
|
||||
Box,
|
||||
}
|
||||
|
||||
impl SubtitleBackdrop {
|
||||
pub const ALL: [Self; 2] = [Self::Outline, Self::Box];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Outline => "Outline",
|
||||
Self::Box => "Box",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour with opacity, written `#RRGGBBAA` the way CSS writes one.
|
||||
///
|
||||
/// Not mpv's order: mpv reads `#AARRGGBB`, which nobody hand-editing a file
|
||||
/// would guess. [`Self::to_mpv`] does the translation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct Colour {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
impl Colour {
|
||||
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||||
Self { r, g, b, a }
|
||||
}
|
||||
|
||||
/// `#RRGGBB` or `#RRGGBBAA`, the `#` optional. Six digits are opaque.
|
||||
pub fn parse(text: &str) -> Option<Self> {
|
||||
let hex = text.trim().strip_prefix('#').unwrap_or(text.trim());
|
||||
if !(hex.len() == 6 || hex.len() == 8) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
|
||||
let a = if hex.len() == 8 { byte(6)? } else { 0xFF };
|
||||
Some(Self::rgba(byte(0)?, byte(2)?, byte(4)?, a))
|
||||
}
|
||||
|
||||
/// As mpv's colour options take it.
|
||||
pub fn to_mpv(self) -> String {
|
||||
format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b)
|
||||
}
|
||||
|
||||
pub fn to_array(self) -> [u8; 4] {
|
||||
[self.r, self.g, self.b, self.a]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; 4]> for Colour {
|
||||
fn from([r, g, b, a]: [u8; 4]) -> Self {
|
||||
Self::rgba(r, g, b, a)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Colour {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"#{:02X}{:02X}{:02X}{:02X}",
|
||||
self.r, self.g, self.b, self.a
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Colour> for String {
|
||||
fn from(colour: Colour) -> Self {
|
||||
colour.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Colour {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(text: String) -> std::result::Result<Self, Self::Error> {
|
||||
Self::parse(&text).ok_or_else(|| format!("not a colour: {text:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlaybackPrefs {
|
||||
@@ -73,6 +278,7 @@ impl Default for PlaybackPrefs {
|
||||
autoplay_next: true,
|
||||
auto_skip: false,
|
||||
speed: 1.0,
|
||||
subtitle_style: SubtitleStyle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +299,7 @@ impl PlaybackPrefs {
|
||||
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
|
||||
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
||||
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
||||
self.subtitle_style = self.subtitle_style.sanitized();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -149,6 +356,16 @@ mod tests {
|
||||
autoplay_next: false,
|
||||
auto_skip: true,
|
||||
speed: 1.5,
|
||||
subtitle_style: SubtitleStyle {
|
||||
scale: 1.5,
|
||||
font: Some("Noto Sans".into()),
|
||||
italic: true,
|
||||
colour: Colour::rgba(0xFF, 0xE6, 0x00, 0xFF),
|
||||
backdrop: SubtitleBackdrop::Box,
|
||||
box_colour: Colour::rgba(0x10, 0x10, 0x10, 0x80),
|
||||
restyle_styled: true,
|
||||
..SubtitleStyle::default()
|
||||
},
|
||||
};
|
||||
save(&dirs, &prefs).unwrap();
|
||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||
@@ -156,6 +373,46 @@ mod tests {
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_subtitle_styles_loads_with_the_default_style() {
|
||||
let prefs: PlaybackPrefs =
|
||||
serde_json::from_str(r#"{"volume": 60.0, "subtitles": true, "speed": 1.0}"#).unwrap();
|
||||
assert_eq!(prefs.subtitle_style, SubtitleStyle::default());
|
||||
assert_eq!(prefs.volume, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_is_written_rgba_and_handed_to_mpv_as_argb() {
|
||||
let colour = Colour::parse("#FFE60080").unwrap();
|
||||
assert_eq!(colour, Colour::rgba(0xFF, 0xE6, 0x00, 0x80));
|
||||
assert_eq!(colour.to_string(), "#FFE60080");
|
||||
assert_eq!(colour.to_mpv(), "#80FFE600");
|
||||
assert_eq!(
|
||||
Colour::parse("ffe600"),
|
||||
Some(Colour::rgba(0xFF, 0xE6, 0, 0xFF))
|
||||
);
|
||||
assert_eq!(Colour::parse("#FFE6"), None);
|
||||
assert_eq!(Colour::parse("#GGGGGG"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_style_out_of_range_is_brought_back_into_it() {
|
||||
let style = SubtitleStyle {
|
||||
scale: 40.0,
|
||||
outline_size: -3.0,
|
||||
blur: f64::NAN,
|
||||
position: 400.0,
|
||||
font: Some(" ".into()),
|
||||
..SubtitleStyle::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(style.scale, SubtitleStyle::MAX_SCALE);
|
||||
assert_eq!(style.outline_size, 0.0);
|
||||
assert_eq!(style.blur, 0.0);
|
||||
assert_eq!(style.position, 100.0);
|
||||
assert_eq!(style.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
|
||||
let prefs = PlaybackPrefs {
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
//! The rest of what makes the app look the same on every device: the shares,
|
||||
//! the settings, the hand-picked metadata matches, the per-show track choices
|
||||
//! and My List. 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, or a title taken off My List, 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/";
|
||||
const LIST: &str = "list/";
|
||||
|
||||
/// 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 title on My List.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct SyncedListEntry {
|
||||
added_at: i64,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Whether My List changed.
|
||||
pub list: bool,
|
||||
}
|
||||
|
||||
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,
|
||||
/// The same for My List.
|
||||
list_read: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match crate::mylist::load(self.dirs()) {
|
||||
Ok(list) => {
|
||||
observed.list_read = true;
|
||||
for (title, added_at) in list.entries() {
|
||||
if let Ok(value) = serde_json::to_value(SyncedListEntry { added_at }) {
|
||||
observed.values.insert(format!("{LIST}{title}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, "My List was not synced"),
|
||||
}
|
||||
|
||||
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 let Some(title) = key.strip_prefix(LIST) {
|
||||
let added_at = value
|
||||
.map(parse::<SyncedListEntry>)
|
||||
.transpose()?
|
||||
.map(|entry| entry.added_at);
|
||||
let mut list = crate::mylist::load(self.dirs())?;
|
||||
if list.added_at(title) != added_at {
|
||||
list.set(title, added_at);
|
||||
crate::mylist::save(self.dirs(), &list)?;
|
||||
changes.list = true;
|
||||
}
|
||||
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() {
|
||||
let removable = (key.starts_with(SHARE) && observed.shares_listed)
|
||||
|| (key.starts_with(LIST) && observed.list_read);
|
||||
if register.value.is_some()
|
||||
&& removable
|
||||
&& !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,
|
||||
logo_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_title_put_on_my_list_on_one_device_is_on_the_list_of_another() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
laptop.sync(&[], 1);
|
||||
let mut list = crate::mylist::MyList::default();
|
||||
list.add("frieren", 5);
|
||||
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 10);
|
||||
|
||||
let (_, changes) = phone.sync(&[&sent], 20);
|
||||
|
||||
assert!(changes.list);
|
||||
let taken = crate::mylist::load(&phone.store.dirs).unwrap();
|
||||
assert_eq!(taken.added_at("frieren"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_taken_off_my_list_on_one_device_leaves_every_list() {
|
||||
let laptop = Device::new("laptop");
|
||||
let phone = Device::new("phone");
|
||||
let mut list = crate::mylist::MyList::default();
|
||||
list.add("frieren", 5);
|
||||
list.add("one piece", 6);
|
||||
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
|
||||
let (sent, _) = laptop.sync(&[], 10);
|
||||
let (phone_sent, _) = phone.sync(&[&sent], 20);
|
||||
|
||||
let mut list = crate::mylist::load(&phone.store.dirs).unwrap();
|
||||
list.remove("frieren");
|
||||
crate::mylist::save(&phone.store.dirs, &list).unwrap();
|
||||
let (phone_sent, _) = phone.sync(&[&sent, &phone_sent], 30);
|
||||
let (sent, changes) = laptop.sync(&[&phone_sent], 40);
|
||||
|
||||
assert!(changes.list);
|
||||
let left = crate::mylist::load(&laptop.store.dirs).unwrap();
|
||||
assert!(!left.contains("frieren"));
|
||||
assert!(left.contains("one piece"));
|
||||
|
||||
// Put back later, it is back everywhere: the removal is older.
|
||||
let mut list = left;
|
||||
list.add("frieren", 50);
|
||||
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
|
||||
let (sent, _) = laptop.sync(&[&sent], 50);
|
||||
phone.sync(&[&sent], 60);
|
||||
assert!(
|
||||
crate::mylist::load(&phone.store.dirs)
|
||||
.unwrap()
|
||||
.contains("frieren")
|
||||
);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! The last few searches, offered again under an empty search box.
|
||||
//!
|
||||
//! Kept on this device only, in `recent-searches.json` beside the other
|
||||
//! settings, and never synced: what someone typed is not a setting, and
|
||||
//! another device's list is not what they were just looking for.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::Result;
|
||||
|
||||
/// How many searches are kept.
|
||||
pub const KEEP: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecentSearches {
|
||||
/// Newest first.
|
||||
#[serde(default)]
|
||||
pub queries: Vec<String>,
|
||||
}
|
||||
|
||||
impl RecentSearches {
|
||||
/// Put `query` first, saying whether the list changed. The same search
|
||||
/// in other letter case moves rather than repeats, and one under two
|
||||
/// letters is not kept.
|
||||
pub fn remember(&mut self, query: &str) -> bool {
|
||||
let query = query.trim();
|
||||
if query.chars().count() < 2 {
|
||||
return false;
|
||||
}
|
||||
if self.queries.first().map(String::as_str) == Some(query) {
|
||||
return false;
|
||||
}
|
||||
let lower = query.to_lowercase();
|
||||
self.queries.retain(|kept| kept.to_lowercase() != lower);
|
||||
self.queries.insert(0, query.to_owned());
|
||||
self.queries.truncate(KEEP);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn file(dirs: &AppDirs) -> PathBuf {
|
||||
dirs.config.join("recent-searches.json")
|
||||
}
|
||||
|
||||
/// The list, empty when there is no file yet.
|
||||
pub fn load(dirs: &AppDirs) -> Result<RecentSearches> {
|
||||
Ok(read_json::<RecentSearches>(&file(dirs))?.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn save(dirs: &AppDirs, recent: &RecentSearches) -> Result<()> {
|
||||
write_json(&file(dirs), recent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_search_repeated_moves_to_the_front_instead_of_appearing_twice() {
|
||||
let mut recent = RecentSearches::default();
|
||||
assert!(recent.remember("frieren"));
|
||||
assert!(recent.remember("kon"));
|
||||
assert!(recent.remember("Frieren "));
|
||||
assert_eq!(recent.queries, ["Frieren", "kon"]);
|
||||
assert!(!recent.remember("Frieren"));
|
||||
assert!(!recent.remember("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_newest_searches_are_kept() {
|
||||
let mut recent = RecentSearches::default();
|
||||
for at in 0..KEEP + 3 {
|
||||
recent.remember(&format!("search {at}"));
|
||||
}
|
||||
assert_eq!(recent.queries.len(), KEEP);
|
||||
assert_eq!(recent.queries[0], format!("search {}", KEEP + 2));
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,13 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use proton_drive_rs::{
|
||||
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
||||
};
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -355,19 +357,19 @@ impl AccountFolder {
|
||||
}
|
||||
|
||||
/// The secrets for one share, as held in the credential store.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ShareSecrets {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct ShareSecrets {
|
||||
/// The full share URL, fragment included.
|
||||
url: String,
|
||||
pub(crate) url: String,
|
||||
/// The custom password, when the link has one.
|
||||
#[serde(default)]
|
||||
custom_password: Option<String>,
|
||||
pub(crate) custom_password: Option<String>,
|
||||
}
|
||||
|
||||
/// The configured shares, and their secrets.
|
||||
pub struct ShareStore {
|
||||
dirs: AppDirs,
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
pub(crate) dirs: AppDirs,
|
||||
pub(crate) secrets: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl ShareStore {
|
||||
@@ -396,9 +398,9 @@ impl ShareStore {
|
||||
|
||||
let mut shares = self.list()?;
|
||||
if shares.iter().any(|share| share.id == id) {
|
||||
return Err(Error::Config(format!(
|
||||
"a share for token {token} is already configured"
|
||||
)));
|
||||
return Err(Error::Config(
|
||||
"that link is already in the library".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let secrets = ShareSecrets {
|
||||
@@ -421,6 +423,80 @@ impl ShareStore {
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// [`Self::add`], but only once the link has been opened with the
|
||||
/// password given. An empty `name` takes the shared folder's own.
|
||||
///
|
||||
/// A link that is mistyped, expired or behind the wrong password used to
|
||||
/// be stored anyway, so the first try failed on the crawl and every retry
|
||||
/// failed on "already configured" — the only way out was to remove a share
|
||||
/// that had never worked. Opening first means a refused link leaves
|
||||
/// nothing behind, and the client it opened is handed back for the crawl.
|
||||
pub async fn add_verified(
|
||||
&self,
|
||||
name: &str,
|
||||
url: &str,
|
||||
custom_password: Option<&str>,
|
||||
) -> Result<(Share, ShareClient)> {
|
||||
let token = token_from_url(url)?;
|
||||
let id = format!("share-{token}");
|
||||
if self.list()?.iter().any(|share| share.id == id) {
|
||||
return Err(Error::Config(
|
||||
"that link is already in the library".to_owned(),
|
||||
));
|
||||
}
|
||||
let custom_password = custom_password.filter(|password| !password.is_empty());
|
||||
let client = match open_link(url, custom_password).await {
|
||||
Ok(client) => client,
|
||||
// Asked only after a refusal: the link's public flags cost a round
|
||||
// trip, and most links open without one.
|
||||
Err(error) if custom_password.is_none() => {
|
||||
let protected = ProtonDrivePublicLinkClient::info(client_configuration(), url)
|
||||
.await
|
||||
.is_ok_and(|info| info.is_custom_password_protected);
|
||||
return Err(if protected {
|
||||
Error::PasswordNeeded
|
||||
} else {
|
||||
error
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
// A name is the viewer's to give, not theirs to invent before they
|
||||
// can see anything: without one, the shared folder's own name.
|
||||
let name = match name.trim() {
|
||||
"" => client
|
||||
.root_node()
|
||||
.await
|
||||
.ok()
|
||||
.map(|root| root.name)
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| "Shared folder".to_owned()),
|
||||
given => given.to_owned(),
|
||||
};
|
||||
let share = self.add(&name, url, custom_password)?;
|
||||
Ok((share, client))
|
||||
}
|
||||
|
||||
/// Record a share another installation has, with the secrets it opens
|
||||
/// with, replacing what this one holds under the same id. See
|
||||
/// [`crate::profile`].
|
||||
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
|
||||
if let Some(secrets) = secrets {
|
||||
self.store_secrets(&share.id, secrets)?;
|
||||
}
|
||||
let mut shares = self.list()?;
|
||||
match shares.iter_mut().find(|existing| existing.id == share.id) {
|
||||
Some(existing) => *existing = share,
|
||||
None => shares.push(share),
|
||||
}
|
||||
write_json(&self.dirs.shares_file(), &shares)
|
||||
}
|
||||
|
||||
/// A share's secrets, for syncing them to another installation.
|
||||
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
|
||||
self.load_secrets(id)
|
||||
}
|
||||
|
||||
/// Record a folder of the signed-in account as a share.
|
||||
///
|
||||
/// Nothing goes in the credential store: the folder opens through the
|
||||
@@ -493,6 +569,25 @@ impl ShareStore {
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Call a share something else. Only the name changes: the id, which the
|
||||
/// catalog, watch state and offline files are keyed by, stays. Sync sees
|
||||
/// the new name as a change to the share's register, like any other.
|
||||
pub fn rename(&self, id: &str, name: &str) -> Result<Share> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(Error::Config("a share needs a name".to_owned()));
|
||||
}
|
||||
let mut shares = self.list()?;
|
||||
let share = shares
|
||||
.iter_mut()
|
||||
.find(|share| share.id == id)
|
||||
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
|
||||
share.name = name.to_owned();
|
||||
let renamed = share.clone();
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
Ok(renamed)
|
||||
}
|
||||
|
||||
/// Forget a share and its secrets.
|
||||
///
|
||||
/// The config entry goes first here, for the mirror-image reason: if the
|
||||
@@ -526,17 +621,7 @@ impl ShareStore {
|
||||
});
|
||||
}
|
||||
let secrets = self.load_secrets(&share.id)?;
|
||||
let client = ProtonDrivePublicLinkClient::open(
|
||||
client_configuration(),
|
||||
&secrets.url,
|
||||
secrets.custom_password.as_deref(),
|
||||
)
|
||||
.await?
|
||||
// The SDK default is sized for a background sync daemon. A player that
|
||||
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
||||
// ceiling, which is unremarkable for a desktop app.
|
||||
.with_max_inflight_blocks(48);
|
||||
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||
open_link(&secrets.url, secrets.custom_password.as_deref()).await
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
@@ -556,9 +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.
|
||||
pub struct SharedLibrary {
|
||||
clients: BTreeMap<String, ShareClient>,
|
||||
sessions: SessionAges,
|
||||
}
|
||||
|
||||
/// How long a visitor session is assumed good for without a refresh.
|
||||
///
|
||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||
/// failure that the viewer can only clear by restarting or recrawling.
|
||||
pub const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// When each share's visitor session was last known good.
|
||||
///
|
||||
/// Sessions are short-lived and an app routinely sits open, or in the
|
||||
/// background, for hours between one episode and the next, so age is a reason
|
||||
/// to reauthenticate before opening anything. A share with no entry is stale.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionAges {
|
||||
refreshed: parking_lot::Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl SessionAges {
|
||||
/// Record that `share_id`'s session was good just now.
|
||||
pub fn mark(&self, share_id: &str) {
|
||||
self.refreshed
|
||||
.lock()
|
||||
.insert(share_id.to_owned(), std::time::Instant::now());
|
||||
}
|
||||
|
||||
fn is_stale(&self, share_id: &str) -> bool {
|
||||
self.refreshed
|
||||
.lock()
|
||||
.get(share_id)
|
||||
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR)
|
||||
}
|
||||
|
||||
/// Run `operation`, reauthenticating around it.
|
||||
///
|
||||
/// Two guards, because there are two ways to lose a visitor session. Age is
|
||||
/// the common one, and a timestamp catches it before the request. Anything
|
||||
/// else — a session dropped early, a machine that slept through a clock
|
||||
/// change — only shows up as a failure, so a fresh-looking session gets one
|
||||
/// retry behind a refresh. `refresh` is best-effort: when it fails the
|
||||
/// operation reports the real problem, which is more useful than a refresh
|
||||
/// error standing in for it.
|
||||
pub async fn run<T, E, R, RF, O, OF>(
|
||||
&self,
|
||||
share_id: &str,
|
||||
refresh: R,
|
||||
operation: O,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
R: Fn() -> RF,
|
||||
RF: std::future::Future<Output = bool>,
|
||||
O: Fn() -> OF,
|
||||
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||
{
|
||||
let stale = self.is_stale(share_id);
|
||||
if stale && refresh().await {
|
||||
self.mark(share_id);
|
||||
}
|
||||
match operation().await {
|
||||
Err(_) if !stale => {
|
||||
if refresh().await {
|
||||
self.mark(share_id);
|
||||
}
|
||||
operation().await
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One opened share, whichever kind it is.
|
||||
@@ -680,6 +845,9 @@ impl SharedLibrary {
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
let mut clients = BTreeMap::new();
|
||||
let mut failures = Vec::new();
|
||||
// A reused client's session is of unknown age, so it is left stale; one
|
||||
// opened here has just completed its handshake.
|
||||
let sessions = SessionAges::default();
|
||||
|
||||
for share in store.list()? {
|
||||
if let Some(client) = reusable.remove(&share.id) {
|
||||
@@ -688,13 +856,14 @@ impl SharedLibrary {
|
||||
}
|
||||
match store.open(&share, account).await {
|
||||
Ok(client) => {
|
||||
sessions.mark(&share.id);
|
||||
clients.insert(share.id.clone(), client);
|
||||
}
|
||||
Err(e) => failures.push((share, e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok((Self { clients }, failures))
|
||||
Ok((Self { clients, sessions }, failures))
|
||||
}
|
||||
|
||||
/// The opened clients, minus `dropping`, ready to hand to
|
||||
@@ -735,9 +904,37 @@ impl SharedLibrary {
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||
client.refresh_session().await?;
|
||||
self.sessions.mark(share_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `operation` against one share, refreshing its visitor session first
|
||||
/// when it is old and once more if the operation fails. See
|
||||
/// [`SessionAges::run`]. Every open of something on a share goes through
|
||||
/// here, so an app left running for hours does not need restarting to play.
|
||||
pub async fn reauthenticating<T, E, O, OF>(
|
||||
&self,
|
||||
share_id: &str,
|
||||
operation: O,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
O: Fn() -> OF,
|
||||
OF: std::future::Future<Output = std::result::Result<T, E>>,
|
||||
{
|
||||
self.sessions
|
||||
.run(
|
||||
share_id,
|
||||
|| async {
|
||||
match self.client(share_id) {
|
||||
Some(client) => client.refresh_session().await.is_ok(),
|
||||
None => false,
|
||||
}
|
||||
},
|
||||
operation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Walk one share's whole subtree, level by level, yielding every node.
|
||||
///
|
||||
/// There is no recursion helper on the visitor path — the authenticated
|
||||
@@ -778,7 +975,10 @@ impl SharedLibrary {
|
||||
.client(share_id)
|
||||
.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 found = vec![root];
|
||||
|
||||
@@ -786,14 +986,10 @@ impl SharedLibrary {
|
||||
let listings = level.into_iter().map(|folder| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let child_uids: Vec<NodeUid> =
|
||||
client.enumerate_folder_children_node_uids(&folder).await?;
|
||||
if child_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Chunked and fanned out inside the SDK too, so this is a
|
||||
// multiplier on that concurrency rather than the only one.
|
||||
client.enumerate_nodes(&child_uids).await
|
||||
retrying(&CRAWL_RETRY_DELAYS, ProtonError::is_retriable, || {
|
||||
list_folder(&client, &folder)
|
||||
})
|
||||
.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.
|
||||
///
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// An operation that fails its first `failures` calls, counting every call.
|
||||
fn flaky(
|
||||
failures: usize,
|
||||
calls: &AtomicUsize,
|
||||
) -> impl Fn() -> std::future::Ready<std::result::Result<(), ()>> {
|
||||
move || {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(if call < failures { Err(()) } else { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_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]
|
||||
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
|
||||
/// leave every catalog row and offline file describing files that are not
|
||||
/// there.
|
||||
|
||||
@@ -135,6 +135,46 @@ pub fn pick(
|
||||
picked
|
||||
}
|
||||
|
||||
/// How many titles the "Because you watched" row offers.
|
||||
const BECAUSE: usize = 12;
|
||||
|
||||
/// Every row above the library grid, in the order all three apps show them:
|
||||
/// new episodes of what is being watched, My List, what was recently added,
|
||||
/// what is like the last thing finished, then up to `limit` [`pick`]ed
|
||||
/// shelves.
|
||||
pub fn home(
|
||||
titles: &[Title],
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
names: crate::metadata::TitleNames,
|
||||
list: &crate::mylist::MyList,
|
||||
first_seen: &HashMap<(String, String), i64>,
|
||||
now: i64,
|
||||
limit: usize,
|
||||
) -> Vec<Shelf> {
|
||||
let news = crate::news::rows(titles, first_seen, now);
|
||||
let present: std::collections::HashSet<&str> =
|
||||
titles.iter().map(|title| title.key.as_str()).collect();
|
||||
let keys: Vec<String> = list
|
||||
.newest_first()
|
||||
.into_iter()
|
||||
.filter(|key| present.contains(key))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mine = (!keys.is_empty()).then(|| Shelf {
|
||||
label: "My List".into(),
|
||||
keys,
|
||||
});
|
||||
news.episodes
|
||||
.into_iter()
|
||||
.chain(mine)
|
||||
.chain(news.added)
|
||||
.chain(crate::discover::because_you_watched(
|
||||
titles, metadata, names, BECAUSE,
|
||||
))
|
||||
.chain(pick(titles, metadata, limit))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -168,6 +208,7 @@ mod tests {
|
||||
kind: TitleKind::Film,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: genres.iter().map(|genre| genre.to_string()).collect(),
|
||||
episodes: None,
|
||||
@@ -258,4 +299,25 @@ mod tests {
|
||||
assert_eq!(shelves[0].label, "Airing now");
|
||||
assert_eq!(shelves[0].keys, vec!["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn my_list_is_a_row_of_what_the_library_holds_newest_first() {
|
||||
let titles = vec![title("a"), title("b")];
|
||||
let mut list = crate::mylist::MyList::default();
|
||||
list.add("a", 1);
|
||||
list.add("b", 3);
|
||||
list.add("gone", 5);
|
||||
let rows = home(
|
||||
&titles,
|
||||
&HashMap::new(),
|
||||
Default::default(),
|
||||
&list,
|
||||
&HashMap::new(),
|
||||
10,
|
||||
4,
|
||||
);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].label, "My List");
|
||||
assert_eq!(rows[0].keys, ["b", "a"]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! another left off.
|
||||
//! another left off — and, in the same file, the shares, settings and
|
||||
//! hand-picked matches that make the library look the same on every device
|
||||
//! (see [`crate::profile`]).
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
@@ -17,7 +19,8 @@
|
||||
//! nothing is overwritten and there is no write conflict to resolve.
|
||||
//!
|
||||
//! Each file carries the installation's whole watch table, and merging is per
|
||||
//! episode: the newer `updated_at` wins. That is idempotent and order-free, so
|
||||
//! episode: the newer `updated_at` wins. The profile's registers merge the same
|
||||
//! way, per key. That is idempotent and order-free, so
|
||||
//! it does not matter how many devices there are, which synced first, or that
|
||||
//! a device writes back entries it learned from another. A device whose clock
|
||||
//! runs ahead wins ties it should not, which is the price of not having a
|
||||
@@ -25,11 +28,11 @@
|
||||
//!
|
||||
//! The keys travel: `share-{token}` is derived from the link and a folder's
|
||||
//! `drive-…` id from its node, so the same library added on two devices has
|
||||
//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the
|
||||
//! link token decrypts nothing without the fragment — and the file is
|
||||
//! end-to-end encrypted by Drive regardless.
|
||||
//! the same `(share_id, link_id)` on both. The watch entries carry nothing
|
||||
//! secret; the profile's share registers do carry each link and its password,
|
||||
//! which is why they live only here, end-to-end encrypted by Drive.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
|
||||
@@ -40,6 +43,8 @@ use crate::account::Account;
|
||||
use crate::catalog::{Catalog, WatchState};
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::{Profile, ProfileChanges, Registers};
|
||||
use crate::shares::ShareStore;
|
||||
|
||||
/// The Drive device watch history is kept on.
|
||||
pub const DEVICE_NAME: &str = "proton-stream";
|
||||
@@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream";
|
||||
const FOLDER: &str = "watch-history";
|
||||
|
||||
/// Bumped only for a change an older build would misread. A file of a newer
|
||||
/// version is skipped rather than half-understood.
|
||||
/// version is skipped rather than half-understood. The profile's registers
|
||||
/// were added without a bump: a build that predates them ignores the field.
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// One installation's watch history, as stored in Drive.
|
||||
@@ -57,6 +63,15 @@ struct HistoryFile {
|
||||
version: u32,
|
||||
installation: String,
|
||||
entries: Vec<HistoryEntry>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
registers: Registers,
|
||||
}
|
||||
|
||||
/// What one history file holds.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
struct Contents {
|
||||
entries: Vec<HistoryEntry>,
|
||||
registers: Registers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -112,7 +127,7 @@ struct Identity {
|
||||
}
|
||||
|
||||
/// What one [`WatchSync::sync`] did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SyncReport {
|
||||
/// Episodes whose position another device had newer.
|
||||
pub applied: usize,
|
||||
@@ -123,6 +138,8 @@ pub struct SyncReport {
|
||||
/// Files of installations gone quiet that were moved to the trash, their
|
||||
/// history now carried in this installation's own file.
|
||||
pub retired: usize,
|
||||
/// What other devices' shares and settings changed here.
|
||||
pub profile: ProfileChanges,
|
||||
}
|
||||
|
||||
/// How long an installation's file may go unwritten before another
|
||||
@@ -243,10 +260,10 @@ impl HistoryDrive for ProtonDriveClient {
|
||||
}
|
||||
|
||||
/// A history file as last read, by the revision it was read at. `None`
|
||||
/// entries for one this build could not read.
|
||||
/// contents for one this build could not read.
|
||||
struct Seen {
|
||||
revision: Option<String>,
|
||||
entries: Option<Vec<HistoryEntry>>,
|
||||
contents: Option<Contents>,
|
||||
}
|
||||
|
||||
/// Syncs the catalog's watch table with the account's Drive.
|
||||
@@ -257,8 +274,9 @@ pub struct WatchSync {
|
||||
seen: Mutex<HashMap<String, Seen>>,
|
||||
/// What this installation's own file holds, once known: what was read
|
||||
/// back at the first sync, then what was last written. Compared against
|
||||
/// the catalog so an unchanged history is not uploaded as a new revision.
|
||||
own: Mutex<Option<Vec<HistoryEntry>>>,
|
||||
/// the catalog and the profile so an unchanged history is not uploaded as
|
||||
/// a new revision.
|
||||
own: Mutex<Option<Contents>>,
|
||||
/// The history folders, resolved once per account. Usually one; see
|
||||
/// [`HistoryDrive::history_folders`].
|
||||
folders: Mutex<Option<Vec<NodeUid>>>,
|
||||
@@ -303,25 +321,34 @@ impl WatchSync {
|
||||
*self.folders.lock() = None;
|
||||
}
|
||||
|
||||
/// Pull every other installation's history into `catalog`, write this
|
||||
/// one's if it changed, then retire the files of installations gone quiet.
|
||||
/// Pull every other installation's history, shares and settings in, write
|
||||
/// this one's if it changed, then retire the files of installations gone
|
||||
/// quiet.
|
||||
///
|
||||
/// The catalog is locked only around its own reads and writes, never
|
||||
/// across a request, so playback can keep saving its position while this
|
||||
/// runs.
|
||||
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
|
||||
/// runs. Shares that arrive are configured but not opened or crawled; see
|
||||
/// [`SyncReport::profile`] for what the front end has to do about them.
|
||||
pub async fn sync(
|
||||
&self,
|
||||
account: &Account,
|
||||
catalog: &Mutex<Catalog>,
|
||||
store: &ShareStore,
|
||||
) -> Result<SyncReport> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |since| since.as_secs() as i64);
|
||||
self.sync_with(account.drive(), catalog, now).await
|
||||
let profile = Profile { store, catalog };
|
||||
self.sync_with(account.drive(), &profile, now).await
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_with<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
catalog: &Mutex<Catalog>,
|
||||
profile: &Profile<'_>,
|
||||
now: i64,
|
||||
) -> Result<SyncReport> {
|
||||
let catalog = profile.catalog;
|
||||
let _running = self.running.lock().await;
|
||||
let folders = self.folders(drive).await?;
|
||||
let own_name = format!("{}.json", self.installation);
|
||||
@@ -339,12 +366,14 @@ impl WatchSync {
|
||||
}
|
||||
|
||||
let mut remote = Vec::new();
|
||||
let mut registers = Vec::new();
|
||||
// Files whose every entry has been read, and so can be retired once
|
||||
// this installation's file carries them.
|
||||
let mut readable = Vec::new();
|
||||
for file in &others {
|
||||
if let Some(entries) = self.read(drive, file).await {
|
||||
remote.extend(entries);
|
||||
if let Some(contents) = self.read(drive, file).await {
|
||||
remote.extend(contents.entries);
|
||||
registers.push(contents.registers);
|
||||
readable.push(file);
|
||||
}
|
||||
}
|
||||
@@ -355,19 +384,22 @@ impl WatchSync {
|
||||
// less than it holds, and read again next time.
|
||||
let mut own_unreadable = false;
|
||||
if self.own.lock().is_none() {
|
||||
let entries = match &own_file {
|
||||
let contents = match &own_file {
|
||||
Some(file) => self.read(drive, file).await,
|
||||
None => Some(Vec::new()),
|
||||
None => Some(Contents::default()),
|
||||
};
|
||||
match entries {
|
||||
Some(entries) => {
|
||||
remote.extend(entries.iter().cloned());
|
||||
*self.own.lock() = Some(entries);
|
||||
match contents {
|
||||
Some(contents) => {
|
||||
remote.extend(contents.entries.iter().cloned());
|
||||
registers.push(contents.registers.clone());
|
||||
*self.own.lock() = Some(contents);
|
||||
}
|
||||
None => own_unreadable = true,
|
||||
}
|
||||
}
|
||||
|
||||
let (registers, profile_changes) = profile.sync(®isters, now)?;
|
||||
|
||||
let incoming: Vec<(String, String, WatchState)> = remote
|
||||
.iter()
|
||||
.filter(|entry| entry.is_sane())
|
||||
@@ -388,15 +420,17 @@ impl WatchSync {
|
||||
// A file that is missing is written even when what it would hold has
|
||||
// not changed: another installation retired it, or it was deleted by
|
||||
// hand, and either way the history it carried lives only here now.
|
||||
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
|
||||
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
|
||||
{
|
||||
let contents = Contents { entries, registers };
|
||||
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&contents);
|
||||
let empty = contents.entries.is_empty() && contents.registers.is_empty();
|
||||
let uploaded = if own_unreadable || unchanged || (empty && own_file.is_none()) {
|
||||
false
|
||||
} else {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION,
|
||||
installation: self.installation.clone(),
|
||||
entries: entries.clone(),
|
||||
entries: contents.entries.clone(),
|
||||
registers: contents.registers.clone(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file)
|
||||
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
|
||||
@@ -404,7 +438,7 @@ impl WatchSync {
|
||||
Some(file) => drive.replace(&file.uid, &bytes).await?,
|
||||
None => drive.create(&folders[0], &own_name, &bytes).await?,
|
||||
}
|
||||
*self.own.lock() = Some(entries);
|
||||
*self.own.lock() = Some(contents);
|
||||
true
|
||||
};
|
||||
|
||||
@@ -435,34 +469,31 @@ impl WatchSync {
|
||||
uploaded,
|
||||
devices: others.len(),
|
||||
retired: retired.len(),
|
||||
profile: profile_changes,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entries of one history file, from the cache when its revision has
|
||||
/// The contents of one history file, from the cache when its revision has
|
||||
/// not moved, or `None` when it cannot be read.
|
||||
///
|
||||
/// A file that will not download or parse is logged and skipped: it is
|
||||
/// another device's, and one bad file must not stop the rest syncing.
|
||||
async fn read<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
file: &RemoteFile,
|
||||
) -> Option<Vec<HistoryEntry>> {
|
||||
async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> {
|
||||
let key = file.uid.link_id.as_str().to_owned();
|
||||
if let Some(seen) = self.seen.lock().get(&key)
|
||||
&& file.revision.is_some()
|
||||
&& seen.revision == file.revision
|
||||
{
|
||||
return seen.entries.clone();
|
||||
return seen.contents.clone();
|
||||
}
|
||||
let uid = &file.uid;
|
||||
let entries = match drive.download(uid).await {
|
||||
let contents = match drive.download(uid).await {
|
||||
Ok(bytes) => {
|
||||
let entries = parse(&bytes);
|
||||
if entries.is_none() {
|
||||
let contents = parse(&bytes);
|
||||
if contents.is_none() {
|
||||
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
||||
}
|
||||
entries
|
||||
contents
|
||||
}
|
||||
Err(error) => {
|
||||
// Not cached: a download that failed is tried again next time.
|
||||
@@ -474,10 +505,10 @@ impl WatchSync {
|
||||
key,
|
||||
Seen {
|
||||
revision: file.revision.clone(),
|
||||
entries: entries.clone(),
|
||||
contents: contents.clone(),
|
||||
},
|
||||
);
|
||||
entries
|
||||
contents
|
||||
}
|
||||
|
||||
/// The history folders, resolved once per account.
|
||||
@@ -491,10 +522,13 @@ impl WatchSync {
|
||||
}
|
||||
}
|
||||
|
||||
/// A history file's entries, or `None` for one this build cannot read.
|
||||
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
|
||||
/// A history file's contents, or `None` for one this build cannot read.
|
||||
fn parse(bytes: &[u8]) -> Option<Contents> {
|
||||
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
|
||||
(file.version <= FORMAT_VERSION).then_some(file.entries)
|
||||
(file.version <= FORMAT_VERSION).then_some(Contents {
|
||||
entries: file.entries,
|
||||
registers: file.registers,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind of device to register, which is only the icon the web app draws.
|
||||
@@ -518,6 +552,7 @@ fn new_installation_id() -> Result<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::tests::Device;
|
||||
|
||||
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
|
||||
HistoryEntry {
|
||||
@@ -655,11 +690,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// An installation: its sync and its catalog.
|
||||
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
|
||||
(
|
||||
WatchSync::with_installation(id.to_owned()),
|
||||
Mutex::new(Catalog::in_memory().unwrap()),
|
||||
)
|
||||
fn device(id: &str) -> (WatchSync, Device) {
|
||||
(WatchSync::with_installation(id.to_owned()), Device::new(id))
|
||||
}
|
||||
|
||||
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
|
||||
@@ -684,11 +716,14 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let first = laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first.uploaded);
|
||||
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
|
||||
let second = phone
|
||||
.sync_with(&drive, &phone_catalog.profile(), 110)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second.applied, 1);
|
||||
assert_eq!(second.devices, 1);
|
||||
@@ -714,12 +749,15 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
phone
|
||||
.sync_with(&drive, &phone_catalog.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -740,8 +778,14 @@ mod tests {
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
let again = laptop
|
||||
.sync_with(&drive, &catalog.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!again.uploaded);
|
||||
assert_eq!(drive.uploads(), 1);
|
||||
@@ -751,7 +795,10 @@ mod tests {
|
||||
async fn nothing_is_uploaded_before_anything_was_watched() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
assert!(drive.names().is_empty());
|
||||
}
|
||||
@@ -764,11 +811,17 @@ mod tests {
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
|
||||
drive.trash(&own).await.unwrap();
|
||||
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(report.uploaded);
|
||||
assert_eq!(drive.names(), ["laptop.json"]);
|
||||
@@ -782,11 +835,17 @@ mod tests {
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The catalog was deleted; the config, and with it the id, was not.
|
||||
let (relaunched, empty) = device("laptop");
|
||||
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
|
||||
let report = relaunched
|
||||
.sync_with(&drive, &empty.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.applied, 1);
|
||||
assert!(!report.uploaded);
|
||||
@@ -804,7 +863,10 @@ mod tests {
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (relaunched, other) = device("laptop");
|
||||
other
|
||||
@@ -814,13 +876,19 @@ mod tests {
|
||||
drive
|
||||
.offline
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
|
||||
let report = relaunched
|
||||
.sync_with(&drive, &other.profile(), 200)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
|
||||
drive
|
||||
.offline
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
relaunched.sync_with(&drive, &other, 300).await.unwrap();
|
||||
relaunched
|
||||
.sync_with(&drive, &other.profile(), 300)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = other.lock().all_watch_states().unwrap();
|
||||
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
|
||||
}
|
||||
@@ -832,6 +900,7 @@ mod tests {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "future".to_owned(),
|
||||
entries: vec![entry("ep1", 60.0, 100)],
|
||||
registers: Registers::new(),
|
||||
};
|
||||
drive.put("future.json", serde_json::to_vec(&future).unwrap());
|
||||
let (laptop, catalog) = device("laptop");
|
||||
@@ -841,7 +910,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
|
||||
.sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -860,21 +929,24 @@ mod tests {
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
old_phone
|
||||
.sync_with(&drive, &old_catalog, 100)
|
||||
.sync_with(&drive, &old_catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let later = 100 + RETIRE_AFTER_SECS;
|
||||
drive.at(later);
|
||||
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
|
||||
let report = phone
|
||||
.sync_with(&drive, &catalog.profile(), later)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.retired, 1);
|
||||
assert_eq!(drive.names(), ["phone.json"]);
|
||||
// Nothing was lost: the newcomer's file carries it now.
|
||||
let (fresh, fresh_catalog) = device("fresh");
|
||||
fresh
|
||||
.sync_with(&drive, &fresh_catalog, later)
|
||||
.sync_with(&drive, &fresh_catalog.profile(), later)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -893,13 +965,13 @@ mod tests {
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.sync_with(&drive, &laptop_catalog.profile(), 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let report = phone
|
||||
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
|
||||
.sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -925,9 +997,13 @@ mod tests {
|
||||
version: FORMAT_VERSION,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
registers: Registers::new(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file).unwrap();
|
||||
assert_eq!(parse(&bytes), Some(file.entries));
|
||||
assert_eq!(
|
||||
parse(&bytes).map(|contents| contents.entries),
|
||||
Some(file.entries)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -936,6 +1012,7 @@ mod tests {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
registers: Registers::new(),
|
||||
};
|
||||
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
|
||||
assert_eq!(parse(b"not json"), None);
|
||||
|
||||
@@ -18,7 +18,8 @@ use std::collections::HashSet;
|
||||
|
||||
use pstr_core::library::{self, TitleKind};
|
||||
use pstr_core::metadata::{
|
||||
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
||||
CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
||||
Trailer,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -27,6 +28,7 @@ use crate::error::{Error, Result};
|
||||
use crate::limiter::RateLimiter;
|
||||
use crate::matching::{Candidate, Query};
|
||||
use crate::provider::Provider;
|
||||
use crate::tmdb::{Tmdb, TmdbId};
|
||||
|
||||
const ENDPOINT: &str = "https://graphql.anilist.co";
|
||||
|
||||
@@ -59,7 +61,9 @@ const SCORE_SCALE: f32 = 10.0;
|
||||
/// Everything one entry is asked for, by search and by id alike.
|
||||
///
|
||||
/// The relations are the franchise's — see `pstr_core::franchise` — and the
|
||||
/// staff are only the most relevant few, which is where a director sits.
|
||||
/// staff are only the most relevant few, which is where a director sits. The
|
||||
/// characters are the first ten by role, main ones first, each with their
|
||||
/// Japanese voice actor.
|
||||
macro_rules! entry_fragment {
|
||||
() => {
|
||||
r#"
|
||||
@@ -77,11 +81,20 @@ fragment entry on Media {
|
||||
genres
|
||||
tags { name rank isMediaSpoiler }
|
||||
episodes
|
||||
duration
|
||||
format
|
||||
status
|
||||
siteUrl
|
||||
studios(isMain: true) { nodes { name } }
|
||||
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
|
||||
characters(sort: [ROLE, RELEVANCE, ID], perPage: 10) {
|
||||
edges {
|
||||
role
|
||||
node { name { full } image { medium } }
|
||||
voiceActors(language: JAPANESE, sort: [RELEVANCE, ID]) { name { full } image { medium } }
|
||||
}
|
||||
}
|
||||
trailer { id site }
|
||||
nextAiringEpisode { episode airingAt }
|
||||
relations { edges { relationType node { id type } } }
|
||||
}
|
||||
@@ -158,16 +171,20 @@ pub struct AniList {
|
||||
/// Shared by every lookup in flight — see [`crate::limiter`].
|
||||
limiter: RateLimiter,
|
||||
anizip: AniZip,
|
||||
/// Asked by id for the art TVDB has not got, when a TMDB key is stored.
|
||||
tmdb: Option<Tmdb>,
|
||||
}
|
||||
|
||||
impl AniList {
|
||||
/// `language` is the one episode names are wanted in, where ani.zip has
|
||||
/// them in it.
|
||||
pub fn new(http: reqwest::Client, language: String) -> Self {
|
||||
/// them in it. `tmdb` is asked for the art of entries TVDB has none for —
|
||||
/// see [`Tmdb::art`].
|
||||
pub fn new(http: reqwest::Client, language: String, tmdb: Option<Tmdb>) -> Self {
|
||||
Self {
|
||||
anizip: AniZip::new(http.clone(), language),
|
||||
http,
|
||||
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||
tmdb,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +220,8 @@ impl Provider for AniList {
|
||||
}
|
||||
|
||||
/// The episodes of every entry the library's files are spread across, and
|
||||
/// the series' fanart.
|
||||
/// the series' fanart and logo — from TMDB, by the id ani.zip gives, for
|
||||
/// an entry TVDB has no art for.
|
||||
///
|
||||
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
|
||||
/// four of them, Mushoku Tensei's first is two — so a title's match is
|
||||
@@ -234,6 +252,8 @@ impl Provider for AniList {
|
||||
// The same ids as `visited`, in the order they were read.
|
||||
let mut chain = Vec::new();
|
||||
let mut series_entries = 0;
|
||||
// The match's own: a sequel's art is not the title's.
|
||||
let mut tmdb_id: Option<TmdbId> = None;
|
||||
let mut next = Some(Link {
|
||||
id: first,
|
||||
series: found.kind == TitleKind::Series,
|
||||
@@ -246,9 +266,15 @@ impl Provider for AniList {
|
||||
series_entries
|
||||
});
|
||||
let mapping = self.anizip.mapping(link.id, entry_season).await?;
|
||||
if chain.len() == 1 {
|
||||
tmdb_id = mapping.tmdb;
|
||||
}
|
||||
if enrichment.backdrop_url.is_none() {
|
||||
enrichment.backdrop_url = mapping.fanart;
|
||||
}
|
||||
if enrichment.logo_url.is_none() {
|
||||
enrichment.logo_url = mapping.logo;
|
||||
}
|
||||
// Where two entries both list an episode, the earlier one — the
|
||||
// one closer to the match — keeps it.
|
||||
enrichment.episodes.extend(
|
||||
@@ -264,6 +290,14 @@ impl Provider for AniList {
|
||||
next = self.sequel(link.id, &visited).await?;
|
||||
}
|
||||
|
||||
if let (Some(tmdb), Some(id)) = (&self.tmdb, tmdb_id)
|
||||
&& (enrichment.backdrop_url.is_none() || enrichment.logo_url.is_none())
|
||||
{
|
||||
let art = tmdb.art_or_nothing(id).await;
|
||||
enrichment.backdrop_url = enrichment.backdrop_url.or(art.backdrop);
|
||||
enrichment.logo_url = enrichment.logo_url.or(art.logo);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"{}: {} episodes over {} entries",
|
||||
found.name,
|
||||
@@ -308,7 +342,6 @@ impl AniList {
|
||||
Ok(next_in_chain(edges, visited))
|
||||
}
|
||||
|
||||
/// One GraphQL requestimpl AniList {
|
||||
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
||||
/// status and an `errors` array under a 200 — folded into one.
|
||||
///
|
||||
@@ -379,6 +412,12 @@ impl AniList {
|
||||
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
|
||||
/// after the name as written found nothing, which is what keeps it from
|
||||
/// broadening a search that was already working.
|
||||
///
|
||||
/// A film's version number is the same kind of miss. `Evangelion - 1.11 You
|
||||
/// Are (Not) Alone` is the file of a film AniList calls `Evangelion: 1.0 You
|
||||
/// Are (Not) Alone`, and `1.11` is not a word of that name — so the last
|
||||
/// fallback asks without it, and leaves telling the films apart to the scoring,
|
||||
/// which still sees the whole name.
|
||||
fn search_terms(name: &str) -> Vec<String> {
|
||||
let mut terms = vec![name.to_string()];
|
||||
|
||||
@@ -386,9 +425,31 @@ fn search_terms(name: &str) -> Vec<String> {
|
||||
if depossessed != name {
|
||||
terms.push(depossessed);
|
||||
}
|
||||
let unversioned = without_version_numbers(name);
|
||||
if !unversioned.is_empty() && !terms.contains(&unversioned) {
|
||||
terms.push(unversioned);
|
||||
}
|
||||
terms
|
||||
}
|
||||
|
||||
/// `Evangelion - 1.11 You Are Alone` → `Evangelion - You Are Alone`.
|
||||
///
|
||||
/// A version is digits with a dot in them (`1.11`, `3.0+1.01`); a bare number
|
||||
/// is left alone, because in `Kaijuu 8-gou` or `86` it is the name.
|
||||
fn without_version_numbers(name: &str) -> String {
|
||||
name.split(' ')
|
||||
.filter(|word| {
|
||||
let versioned = word.contains('.')
|
||||
&& word.chars().any(|c| c.is_ascii_digit())
|
||||
&& word
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '+'));
|
||||
!versioned
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// `Heavens Feel` → `Heaven Feel`.
|
||||
///
|
||||
/// Only words long enough to still mean something without it, and only where
|
||||
@@ -532,6 +593,8 @@ struct Media {
|
||||
#[serde(default)]
|
||||
tags: Vec<Tag>,
|
||||
episodes: Option<u32>,
|
||||
/// Minutes per episode, or the film's running time.
|
||||
duration: Option<u32>,
|
||||
format: Option<String>,
|
||||
season: Option<String>,
|
||||
status: Option<String>,
|
||||
@@ -539,6 +602,8 @@ struct Media {
|
||||
site_url: Option<String>,
|
||||
studios: Option<Studios>,
|
||||
staff: Option<Staff>,
|
||||
characters: Option<Characters>,
|
||||
trailer: Option<AniTrailer>,
|
||||
#[serde(rename = "nextAiringEpisode")]
|
||||
next_airing_episode: Option<NextAiring>,
|
||||
relations: Option<Relations>,
|
||||
@@ -584,6 +649,32 @@ struct StaffEdge {
|
||||
#[derive(Deserialize)]
|
||||
struct Person {
|
||||
name: PersonName,
|
||||
image: Option<Image>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Image {
|
||||
medium: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Characters {
|
||||
#[serde(default)]
|
||||
edges: Vec<CharacterEdge>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CharacterEdge {
|
||||
role: Option<String>,
|
||||
node: Option<Person>,
|
||||
#[serde(rename = "voiceActors", default)]
|
||||
voice_actors: Vec<Person>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AniTrailer {
|
||||
id: Option<String>,
|
||||
site: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -706,6 +797,37 @@ impl Media {
|
||||
.collect(),
|
||||
popularity: self.popularity,
|
||||
status: self.status,
|
||||
runtime: self.duration.filter(|minutes| *minutes > 0),
|
||||
cast: self
|
||||
.characters
|
||||
.map(|characters| {
|
||||
characters
|
||||
.edges
|
||||
.into_iter()
|
||||
.filter_map(|edge| {
|
||||
let character = edge.node?;
|
||||
let actor = edge.voice_actors.into_iter().next();
|
||||
Some(CastMember {
|
||||
character: character.name.full?,
|
||||
character_image: character.image.and_then(|image| image.medium),
|
||||
role: edge.role,
|
||||
actor_image: actor
|
||||
.as_ref()
|
||||
.and_then(|actor| actor.image.as_ref())
|
||||
.and_then(|image| image.medium.clone()),
|
||||
actor: actor.and_then(|actor| actor.name.full),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
trailer: self.trailer.and_then(|trailer| {
|
||||
Some(Trailer {
|
||||
site: trailer.site?,
|
||||
id: trailer.id?,
|
||||
})
|
||||
}),
|
||||
revision: TitleDetails::REVISION,
|
||||
next_episode: self
|
||||
.next_airing_episode
|
||||
.as_ref()
|
||||
@@ -746,6 +868,8 @@ impl Media {
|
||||
// backdrop is ani.zip's fanart, found when the match is
|
||||
// enriched.
|
||||
backdrop_url: None,
|
||||
// ani.zip's too, like the fanart.
|
||||
logo_url: None,
|
||||
rating: self.average_score.map(|score| score / SCORE_SCALE),
|
||||
genres: self.genres,
|
||||
episodes: self.episodes,
|
||||
@@ -775,7 +899,7 @@ fn kind_of(format: Option<&str>) -> TitleKind {
|
||||
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
|
||||
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
|
||||
/// nothing and `<br>` becomes the line break it stands for.
|
||||
fn strip_markup(text: &str) -> String {
|
||||
pub(crate) fn strip_markup(text: &str) -> String {
|
||||
let text = text
|
||||
.replace("<br>", "\n")
|
||||
.replace("<br/>", "\n")
|
||||
@@ -836,6 +960,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **What three of the four Rebuild films matched as: nothing.** AniList
|
||||
/// requires every word, and a film's version number is not one of its
|
||||
/// title's.
|
||||
#[test]
|
||||
fn a_films_version_number_is_searched_for_again_without_it() {
|
||||
assert_eq!(
|
||||
search_terms("Evangelion - 1.11 You Are Alone"),
|
||||
vec![
|
||||
"Evangelion - 1.11 You Are Alone".to_string(),
|
||||
"Evangelion - You Are Alone".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
search_terms("Evangelion - 3.0+1.01 Thrice Upon a Time")[1],
|
||||
"Evangelion - Thrice Upon a Time"
|
||||
);
|
||||
assert_eq!(
|
||||
search_terms("Kaijuu 8-gou"),
|
||||
vec!["Kaijuu 8-gou"],
|
||||
"a number that is part of the name stays"
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte slicing, on names that are not ASCII.
|
||||
#[test]
|
||||
fn a_multi_byte_name_is_not_cut_inside_a_character() {
|
||||
@@ -894,6 +1041,51 @@ mod tests {
|
||||
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cast_comes_with_its_voice_actors_and_the_trailer_with_its_site() {
|
||||
let media: Media = serde_json::from_str(
|
||||
r#"{
|
||||
"id": 154587,
|
||||
"title": {"romaji": "Sousou no Frieren", "english": null, "native": null},
|
||||
"characters": {"edges": [
|
||||
{"role": "MAIN",
|
||||
"node": {"name": {"full": "Frieren"}, "image": {"medium": "frieren.png"}},
|
||||
"voiceActors": [{"name": {"full": "Atsumi Tanezaki"},
|
||||
"image": {"medium": "tanezaki.png"}}]},
|
||||
{"role": "SUPPORTING",
|
||||
"node": {"name": {"full": "Himmel"}, "image": null},
|
||||
"voiceActors": []},
|
||||
{"role": "BACKGROUND", "node": {"name": {"full": null}}, "voiceActors": []}
|
||||
]},
|
||||
"trailer": {"id": "tR8YH0G67Rk", "site": "youtube"}
|
||||
}"#,
|
||||
)
|
||||
.expect("parse");
|
||||
let details = media.into_candidate().metadata.details.expect("details");
|
||||
assert_eq!(
|
||||
details.cast,
|
||||
[
|
||||
CastMember {
|
||||
character: "Frieren".into(),
|
||||
character_image: Some("frieren.png".into()),
|
||||
role: Some("MAIN".into()),
|
||||
actor: Some("Atsumi Tanezaki".into()),
|
||||
actor_image: Some("tanezaki.png".into()),
|
||||
},
|
||||
// No voice actor yet is still a character; no name is not.
|
||||
CastMember {
|
||||
character: "Himmel".into(),
|
||||
role: Some("SUPPORTING".into()),
|
||||
..CastMember::default()
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
details.trailer.and_then(|trailer| trailer.url()).as_deref(),
|
||||
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
|
||||
);
|
||||
}
|
||||
|
||||
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
|
||||
RelationEdge {
|
||||
relation_type: Some(relation.into()),
|
||||
@@ -971,6 +1163,8 @@ mod tests {
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
runtime: None,
|
||||
mal_id: None,
|
||||
};
|
||||
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
|
||||
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
|
||||
|
||||