Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.4.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"futures",
|
||||
@@ -5142,7 +5299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "1.4.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5176,7 +5333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "1.4.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5195,7 +5352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "1.4.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -5215,7 +5372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "1.4.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -5228,7 +5385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "1.4.1"
|
||||
version = "1.6.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
@@ -5243,7 +5400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "1.4.1"
|
||||
version = "1.6.2"
|
||||
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.4.1"
|
||||
version = "1.6.2"
|
||||
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.
|
||||
@@ -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">
|
||||
@@ -39,6 +47,7 @@
|
||||
<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 {
|
||||
@@ -338,7 +350,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 +387,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 +431,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 +441,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 +476,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 +605,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 +839,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 +938,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 +980,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);
|
||||
|
||||
@@ -21,6 +21,7 @@ import io.narl.protonstream.native.NativeRuntime
|
||||
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
|
||||
@@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() {
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
NewEpisodesWorker.schedule(this)
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -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)
|
||||
@@ -19,7 +19,10 @@ import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import uniffi.pstr_android.ChapterPlan
|
||||
import uniffi.pstr_android.ChapterRecord
|
||||
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
|
||||
@@ -107,6 +110,10 @@ data class MpvTrack(
|
||||
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" }
|
||||
@@ -128,6 +135,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
@Volatile private var currentMedia: String? = null
|
||||
@Volatile private var currentGeneration: Double = -1.0
|
||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||
/** The load generation whose subtitle pick has been looked at. */
|
||||
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||
var onPlaybackStarted: (() -> Unit)? = null
|
||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||
var onExplicitStop: (() -> Unit)? = null
|
||||
@@ -245,7 +254,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) {
|
||||
@@ -292,6 +301,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 +355,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()
|
||||
@@ -359,13 +398,32 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the dialogue rather than the signs and songs mpv picked by language
|
||||
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
|
||||
* have made, not the viewer's.
|
||||
*/
|
||||
private fun preferFullSubtitles() {
|
||||
refreshTracks()
|
||||
val subtitles = mutableTracks.value.filter { it.type == "sub" }
|
||||
val id = fullSubtitlesInstead(
|
||||
subtitles.map {
|
||||
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
|
||||
},
|
||||
) ?: return
|
||||
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
|
||||
}
|
||||
|
||||
private fun refreshTracks() {
|
||||
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 +472,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 +500,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 +509,7 @@ 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
|
||||
|
||||
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,6 +35,8 @@ 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 and rotation. */
|
||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
||||
private var notified: NotificationShape? = null
|
||||
/** The pending teardown, held so an episode transition can call it off. */
|
||||
private var retire: Job? = null
|
||||
/** The open episode's latest reading, which [track] saves. */
|
||||
private var lastReading: MpvPlaybackState? = null
|
||||
private var lastSave = 0L
|
||||
/** The episode's artwork, and the source key it was fetched for. */
|
||||
private var artwork: Pair<String, Bitmap>? = null
|
||||
private val binder = PlaybackBinder()
|
||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it) }
|
||||
onStateChanged = { updateSession(it); track(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Swiped away from Recents. With background audio on, this service would
|
||||
* otherwise play on with no screen to return to and no autoplay, so it
|
||||
* saves where the episode got to and stops.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
lastReading?.let { reading ->
|
||||
// Bounded and on this thread: the process may go as soon as the
|
||||
// service stops, and a save launched into its scope would go with it.
|
||||
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||
}
|
||||
host?.stop()
|
||||
retirePlayback()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the resume position from here rather than from the player screen.
|
||||
*
|
||||
* This service outlives the activity whenever background audio is on, and a
|
||||
* position the screen alone kept stopped being saved the moment the screen
|
||||
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||
* change saves the outgoing episode's last reading before the new one's.
|
||||
*/
|
||||
private fun track(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
if (state.duration <= 0.0) return
|
||||
val previous = lastReading
|
||||
lastReading = state
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val due = when {
|
||||
previous != null && previous.media != media -> {
|
||||
scope.launch(Dispatchers.IO) { save(previous) }
|
||||
true
|
||||
}
|
||||
state.ended -> previous?.ended != true
|
||||
state.paused -> previous?.paused != true
|
||||
else -> now - lastSave >= SAVE_EVERY_MS
|
||||
}
|
||||
if (due) {
|
||||
lastSave = now
|
||||
scope.launch(Dispatchers.IO) { save(state) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
// Played to the end, or near enough that what is left is credits. A
|
||||
// failure or a stop also "ends", and recording either as watched would
|
||||
// retire an episode nobody saw.
|
||||
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||
runCatching {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
media.substringBeforeLast(':'),
|
||||
media.substringAfterLast(':'),
|
||||
state.position.coerceIn(0.0, state.duration),
|
||||
state.duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
refreshSession(state, host?.nowPlaying?.value)
|
||||
if (state.ended) scheduleRetire() else cancelRetire()
|
||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
||||
private const val TRANSITION_GRACE_MS = 30_000L
|
||||
/** How far the volume drops for a navigation prompt or a notification. */
|
||||
private const val DUCK_FACTOR = 0.3
|
||||
private const val SAVE_EVERY_MS = 5_000L
|
||||
/** How long a swipe from Recents may hold the main thread to save. */
|
||||
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||
|
||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -12,13 +13,13 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Forward30
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
@@ -47,6 +49,8 @@ import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material.icons.filled.Subtitles
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -58,9 +62,23 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -70,6 +88,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -106,6 +125,7 @@ import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TrackPreferencesRecord
|
||||
import uniffi.pstr_android.skipOffer
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** How long the controls stay up after a tap. */
|
||||
@@ -114,8 +134,17 @@ private const val CONTROLS_SECONDS = 4L
|
||||
/** How long the "up next" card counts down before it loads the next episode. */
|
||||
private const val UP_NEXT_SECONDS = 10
|
||||
|
||||
/** How far a double tap on either side of the picture seeks. */
|
||||
private const val DOUBLE_TAP_SECONDS = 10
|
||||
|
||||
/** How long the double-tap seek mark stays up. */
|
||||
private const val NUDGE_MS = 700L
|
||||
|
||||
/** How close to the end the next episode's opening blocks are fetched. */
|
||||
private const val WARM_NEXT_SECONDS = 120.0
|
||||
|
||||
/** Past this much of an episode it counts as watched. */
|
||||
private const val WATCHED_FRACTION = 0.9
|
||||
internal const val WATCHED_FRACTION = 0.9
|
||||
|
||||
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||
private const val MAX_VOLUME = 100f
|
||||
@@ -126,7 +155,10 @@ private const val MAX_VOLUME = 100f
|
||||
* Below it — a phone held upright — the transport row is already five controls
|
||||
* wide, and the device's own volume keys are the better answer.
|
||||
*/
|
||||
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
|
||||
private val VOLUME_SLIDER_MIN_WIDTH = 720.dp
|
||||
|
||||
/** Narrower than this, the pickers past subtitles go behind "More". */
|
||||
private val INLINE_CONTROLS_MIN_WIDTH = 560.dp
|
||||
|
||||
/**
|
||||
* The player, as its own screen on the activity's own window.
|
||||
@@ -184,8 +216,16 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
val key = mediaKey(episode)
|
||||
val session = remember(key) { RustStreamSession(episode) }
|
||||
// Retry opens the episode again from scratch: a new stream, through the
|
||||
// bridge's session refresh, from where the failed one stopped.
|
||||
var attempt by remember(key) { mutableIntStateOf(0) }
|
||||
var retryFrom by remember(key) { mutableStateOf<Double?>(null) }
|
||||
val session = remember(key, attempt) { RustStreamSession(episode) }
|
||||
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
||||
// From the click until mpv has the file: the share session, the revision
|
||||
// and its block list all come first, and on a slow link that is seconds of
|
||||
// black screen unless something says so.
|
||||
var opening by remember(key, attempt) { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(session, host) {
|
||||
runCatching {
|
||||
@@ -204,7 +244,7 @@ fun PlayerScreen(
|
||||
size = session.size(),
|
||||
// A finished episode restarts rather than resuming three
|
||||
// seconds from its own credits.
|
||||
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||
startPosition = retryFrom ?: watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
||||
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
||||
subtitles = show?.subtitles ?: global.subtitles,
|
||||
@@ -218,12 +258,21 @@ fun PlayerScreen(
|
||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||
SettingsStore(context).hardwareDecoding,
|
||||
)
|
||||
|
||||
// After the load, because mpv keeps none of the three across one.
|
||||
nativeHost.setVolume(startup.volume)
|
||||
nativeHost.setMuted(startup.muted)
|
||||
nativeHost.setSpeed(startup.speed)
|
||||
session.transferToPlayer()
|
||||
// While mpv opens the episode, and off the spinner's path: the host
|
||||
// holds them until it has the file.
|
||||
launch(Dispatchers.IO) {
|
||||
val files = NativeRuntime.engine()
|
||||
.openSubtitles(episode.shareId, episode.linkId, startup.subtitleLanguage)
|
||||
nativeHost.addSubtitles(key, files, startup.subtitles)
|
||||
}
|
||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||
opening = false
|
||||
}
|
||||
|
||||
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||
@@ -261,12 +310,8 @@ fun PlayerScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(key) {
|
||||
while (true) {
|
||||
delay(5_000)
|
||||
save()
|
||||
}
|
||||
}
|
||||
// The five-second save is PlaybackService's, which outlives this screen;
|
||||
// these are the moments only the screen sees.
|
||||
DisposableEffect(key) { onDispose { save() } }
|
||||
|
||||
val next = episodes.getOrNull(index + 1)
|
||||
@@ -318,8 +363,10 @@ fun PlayerScreen(
|
||||
|
||||
// Autoplay fires on a clean end of file only, and only for this episode's
|
||||
// own reading — never on a failure, and never on the outgoing file's.
|
||||
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||
var stay by remember(key) { mutableStateOf(false) }
|
||||
LaunchedEffect(state.endReason, state.media) {
|
||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
|
||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
|
||||
}
|
||||
// A file mpv could not play ends like any other. Saying so is the whole
|
||||
// difference between a broken episode and an app that silently skipped one.
|
||||
@@ -328,6 +375,14 @@ fun PlayerScreen(
|
||||
playbackError = "This episode could not be played."
|
||||
}
|
||||
}
|
||||
// A stall that does not clear is a dropped connection far more often than
|
||||
// a slow one, and "Buffering…" for ever offers no way out of it.
|
||||
LaunchedEffect(state.buffering, key, attempt) {
|
||||
if (state.buffering) {
|
||||
delay(STALL_LIMIT_MS)
|
||||
playbackError = "Still loading. The connection may have dropped."
|
||||
}
|
||||
}
|
||||
// What mpv itself complained about, which the play call never sees: by the
|
||||
// time a header turns out to be unreadable it has long since returned.
|
||||
val problem by nativeHost.problem.collectAsState()
|
||||
@@ -341,6 +396,21 @@ fun PlayerScreen(
|
||||
if (!playingThis) return@remember null
|
||||
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
||||
}
|
||||
// Near the end, fetch the next episode's opening blocks, so autoplay or
|
||||
// "Play now" starts from the cache. The last two minutes or the credits,
|
||||
// whichever comes first: late enough that stopping after one episode has
|
||||
// not cost a download, early enough to matter.
|
||||
val nearEnd = playingThis && state.duration > 0.0 && (
|
||||
state.duration - state.position < WARM_NEXT_SECONDS ||
|
||||
chapters.creditsStart?.let { state.position >= it } == true
|
||||
)
|
||||
LaunchedEffect(key, nearEnd) {
|
||||
val upcoming = next ?: return@LaunchedEffect
|
||||
if (!nearEnd) return@LaunchedEffect
|
||||
runCatching {
|
||||
NativeRuntime.engine().warmEpisode(upcoming.shareId, upcoming.volumeId, upcoming.linkId)
|
||||
}
|
||||
}
|
||||
// Auto-skip is offered once per chapter: seeking back into an opening on
|
||||
// purpose must not be undone by the setting that skipped it.
|
||||
val skipped = remember(key) { mutableSetOf<Long>() }
|
||||
@@ -351,24 +421,105 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
var landscapeLocked by remember { mutableStateOf(false) }
|
||||
// Set by the rotation button: from then on the viewer decides.
|
||||
var rotationChosen by remember { mutableStateOf(false) }
|
||||
// A wide picture fills a phone only on its side, so it turns there by
|
||||
// itself, once per episode, until the viewer picks for themselves.
|
||||
LaunchedEffect(key, playingThis, state.videoWidth > state.videoHeight) {
|
||||
if (!playingThis || rotationChosen || landscapeLocked) return@LaunchedEffect
|
||||
if (state.videoHeight > 0.0 && state.videoWidth > state.videoHeight) {
|
||||
landscapeLocked = activity?.lockLandscape() ?: false
|
||||
}
|
||||
}
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(showControls, state.paused) {
|
||||
if (showControls && !state.paused) {
|
||||
// TalkBack moves between controls by swiping, and a control that fades
|
||||
// four seconds after it is reached cannot be found again.
|
||||
val exploring = rememberTouchExploration()
|
||||
LaunchedEffect(exploring) { if (exploring) showControls = true }
|
||||
LaunchedEffect(showControls, state.paused, exploring) {
|
||||
if (showControls && !state.paused && !exploring) {
|
||||
delay(CONTROLS_SECONDS * 1_000)
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
// Which side was double-tapped, shown for a moment so the seek is seen
|
||||
// to have happened; the count makes a second tap on one side show again.
|
||||
var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
LaunchedEffect(nudge) {
|
||||
if (nudge != null) {
|
||||
delay(NUDGE_MS)
|
||||
nudge = null
|
||||
}
|
||||
}
|
||||
val current by rememberUpdatedState(state)
|
||||
// Where a remote's keys land while the controls are down. Once they are
|
||||
// up, the D-pad moves between their buttons as it does anywhere else.
|
||||
val keys = remember { FocusRequester() }
|
||||
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
|
||||
if (minimized) return
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { showControls = !showControls },
|
||||
),
|
||||
// A TV remote, a keyboard: left and right seek as a double tap
|
||||
// does, the middle button brings the controls up, and play/pause
|
||||
// and space do what they say.
|
||||
.focusRequester(keys)
|
||||
.onKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||
when (event.key) {
|
||||
Key.DirectionLeft, Key.DirectionRight -> {
|
||||
if (showControls) return@onKeyEvent false
|
||||
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
|
||||
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||
true
|
||||
}
|
||||
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
|
||||
if (showControls) return@onKeyEvent false
|
||||
showControls = true
|
||||
true
|
||||
}
|
||||
Key.MediaPlayPause, Key.Spacebar -> {
|
||||
host.setPaused(!current.paused)
|
||||
showControls = true
|
||||
true
|
||||
}
|
||||
Key.MediaPlay -> { host.setPaused(false); true }
|
||||
Key.MediaPause -> { host.setPaused(true); true }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
// Double-tap a side to seek ten seconds, as every phone video
|
||||
// player has taught; the middle plays or pauses. A single tap
|
||||
// waits out the double-tap timeout, which is the price of both.
|
||||
.pointerInput(host) {
|
||||
detectTapGestures(
|
||||
onTap = { showControls = !showControls },
|
||||
onDoubleTap = { offset ->
|
||||
val third = size.width / 3f
|
||||
val seconds = when {
|
||||
offset.x < third -> -DOUBLE_TAP_SECONDS
|
||||
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
|
||||
else -> 0
|
||||
}
|
||||
if (seconds == 0) {
|
||||
host.setPaused(!current.paused)
|
||||
} else {
|
||||
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.semantics {
|
||||
onClick(label = if (showControls) "Hide controls" else "Show controls") {
|
||||
showControls = !showControls
|
||||
true
|
||||
}
|
||||
},
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -460,7 +611,10 @@ fun PlayerScreen(
|
||||
episodes = episodes,
|
||||
playing = index,
|
||||
onEpisode = { jumpTo(it) },
|
||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
||||
onRotate = {
|
||||
rotationChosen = true
|
||||
landscapeLocked = activity?.toggleLandscape() ?: false
|
||||
},
|
||||
landscapeLocked = landscapeLocked,
|
||||
)
|
||||
}
|
||||
@@ -478,21 +632,59 @@ fun PlayerScreen(
|
||||
) { Text(skip.label) }
|
||||
}
|
||||
|
||||
nudge?.let { (seconds, _) ->
|
||||
Box(
|
||||
Modifier
|
||||
.align(if (seconds < 0) Alignment.CenterStart else Alignment.CenterEnd)
|
||||
.padding(horizontal = 48.dp)
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(if (seconds < 0) "−${-seconds} s" else "+$seconds s", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
// A file with no chapters has no credits to count over, so the card
|
||||
// takes its last seconds instead: without it the next episode cut in
|
||||
// with no warning and no way to stay. Counted on the file's own clock,
|
||||
// so it reaches zero at the end of file, which is what advances.
|
||||
val tailLeft = (state.duration - state.position).takeIf {
|
||||
chapters.creditsStart == null && state.duration > UP_NEXT_SECONDS * 6 && it <= UP_NEXT_SECONDS
|
||||
}
|
||||
UpNextCard(
|
||||
next = next,
|
||||
visible = autoplay && !state.paused && playingThis &&
|
||||
chapters.creditsStart?.let { state.position >= it } == true,
|
||||
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
||||
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||
onPlayNow = { advance() },
|
||||
onStay = { stay = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||
)
|
||||
|
||||
// Played to the end with nothing starting after it: the last episode,
|
||||
// autoplay off, or the viewer stayed. A black screen said none of that.
|
||||
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
|
||||
EndCard(
|
||||
finished = episode.label,
|
||||
show = title.displayName,
|
||||
next = next,
|
||||
onPlayNext = { advance() },
|
||||
// mpv unloads a file at its end, so this opens it again.
|
||||
onReplay = { retryFrom = 0.0; attempt++ },
|
||||
onClose = onClose,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
|
||||
// Only while nothing else explains the still picture: an error is the
|
||||
// more useful thing to say, and a spinner over it reads as "still
|
||||
// trying".
|
||||
if (state.stalled && playbackError == null) {
|
||||
if ((opening || state.stalled) && playbackError == null) {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
@@ -504,10 +696,13 @@ fun PlayerScreen(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
if (state.buffering) {
|
||||
if (opening || state.buffering) {
|
||||
Text(
|
||||
state.cachePercent.takeIf { it > 0.0 }
|
||||
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
||||
when {
|
||||
opening -> "Opening ${episode.label}…"
|
||||
state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%"
|
||||
else -> "Buffering…"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
@@ -528,14 +723,29 @@ fun PlayerScreen(
|
||||
// Dismissible, because the message is not always fatal — a
|
||||
// subtitle track that failed to load leaves an episode that
|
||||
// plays perfectly well behind an error that used to be permanent.
|
||||
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
||||
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
Row {
|
||||
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
||||
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
QuietButton(
|
||||
onClick = {
|
||||
retryFrom = state.position.takeIf { state.media == key && it > 0.0 } ?: retryFrom
|
||||
playbackError = null
|
||||
nativeHost.clearProblem()
|
||||
attempt++
|
||||
},
|
||||
) {
|
||||
Text("Retry", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a stall lasts before the player offers to open the episode again. */
|
||||
private const val STALL_LIMIT_MS = 20_000L
|
||||
|
||||
private data class Startup(
|
||||
val nativeHandle: ULong,
|
||||
val size: ULong,
|
||||
@@ -580,6 +790,26 @@ private fun ImmersiveWindow(activity: Activity?) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock to landscape. Reports whether it is. */
|
||||
private fun Activity.lockLandscape(): Boolean {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether TalkBack, or anything else that explores by touch, is on. */
|
||||
@Composable
|
||||
private fun rememberTouchExploration(): Boolean {
|
||||
val context = LocalContext.current
|
||||
val manager = remember(context) { context.getSystemService(AccessibilityManager::class.java) }
|
||||
var exploring by remember { mutableStateOf(manager?.isTouchExplorationEnabled == true) }
|
||||
DisposableEffect(manager) {
|
||||
val listener = AccessibilityManager.TouchExplorationStateChangeListener { exploring = it }
|
||||
manager?.addTouchExplorationStateChangeListener(listener)
|
||||
onDispose { manager?.removeTouchExplorationStateChangeListener(listener) }
|
||||
}
|
||||
return exploring
|
||||
}
|
||||
|
||||
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
|
||||
private fun Activity.toggleLandscape(): Boolean {
|
||||
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
@@ -626,8 +856,15 @@ private fun PlayerControls(
|
||||
}
|
||||
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
||||
val density = LocalDensity.current
|
||||
val roomForVolume = with(density) {
|
||||
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
|
||||
val width = with(density) { LocalWindowInfo.current.containerSize.width.toDp() }
|
||||
val roomForVolume = width >= VOLUME_SLIDER_MIN_WIDTH
|
||||
// A phone in portrait has room for the transport and two more buttons at
|
||||
// a thumb's size. The rest go behind "More" there rather than off the edge.
|
||||
val roomForAll = width >= INLINE_CONTROLS_MIN_WIDTH
|
||||
val toggleMute = {
|
||||
host.setMuted(!state.muted)
|
||||
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
||||
Unit
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -689,45 +926,80 @@ private fun PlayerControls(
|
||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (episodes.size > 1) {
|
||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||
}
|
||||
if (chapters.size > 1) {
|
||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||
}
|
||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||
CompactIcon(
|
||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
"Mute",
|
||||
) {
|
||||
host.setMuted(!state.muted)
|
||||
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
||||
}
|
||||
// The app's own volume, which is not the device's: a file
|
||||
// mastered quiet is turned up here and stays up for the next
|
||||
// one. Only where there is room for it — a phone in portrait
|
||||
// has the system volume keys and no width to spare.
|
||||
if (roomForVolume) {
|
||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||
Slider(
|
||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||
onValueChangeFinished = {
|
||||
val chosen = dragging?.toDouble()
|
||||
dragging = null
|
||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||
},
|
||||
valueRange = 0f..MAX_VOLUME,
|
||||
modifier = Modifier.width(96.dp),
|
||||
if (!roomForAll) {
|
||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||
var more by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
|
||||
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
|
||||
val entries = buildList {
|
||||
if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
|
||||
if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
|
||||
add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
|
||||
add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
|
||||
add(
|
||||
Triple(
|
||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
if (state.muted) "Unmute" else "Mute",
|
||||
toggleMute,
|
||||
),
|
||||
)
|
||||
add(
|
||||
Triple(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onRotate,
|
||||
),
|
||||
)
|
||||
}
|
||||
entries.forEach { (icon, label, action) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
leadingIcon = { Icon(icon, contentDescription = null) },
|
||||
onClick = { more = false; action() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (episodes.size > 1) {
|
||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||
}
|
||||
if (chapters.size > 1) {
|
||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||
}
|
||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||
CompactIcon(
|
||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
if (state.muted) "Unmute" else "Mute",
|
||||
onClick = toggleMute,
|
||||
)
|
||||
// The app's own volume, which is not the device's: a file
|
||||
// mastered quiet is turned up here and stays up for the next
|
||||
// one. Only where there is room for it — a phone in portrait
|
||||
// has the system volume keys and no width to spare.
|
||||
if (roomForVolume) {
|
||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||
Slider(
|
||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||
onValueChangeFinished = {
|
||||
val chosen = dragging?.toDouble()
|
||||
dragging = null
|
||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||
},
|
||||
valueRange = 0f..MAX_VOLUME,
|
||||
modifier = Modifier.width(96.dp),
|
||||
)
|
||||
}
|
||||
CompactIcon(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
}
|
||||
CompactIcon(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,24 +1260,31 @@ private fun EpisodeChooser(
|
||||
*
|
||||
* "Watch till the end" holds for the rest of the file: someone who wants the
|
||||
* post-credits scene should have to say so once, not once every ten seconds.
|
||||
*
|
||||
* With [secondsLeft] the card counts down the end of a file that has no credits
|
||||
* chapter, on the file's own clock; the end of file is what advances, and the
|
||||
* second button is "Cancel", since there are no credits to watch.
|
||||
*/
|
||||
@Composable
|
||||
private fun UpNextCard(
|
||||
next: EpisodeRecord?,
|
||||
visible: Boolean,
|
||||
secondsLeft: Int?,
|
||||
onPlayNow: () -> Unit,
|
||||
onStay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var held by remember(next?.linkId) { mutableStateOf(false) }
|
||||
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||
var counted by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||
val remaining = secondsLeft ?: counted
|
||||
val upNext = next
|
||||
val showing = visible && upNext != null && !held
|
||||
LaunchedEffect(showing) {
|
||||
if (!showing) return@LaunchedEffect
|
||||
remaining = UP_NEXT_SECONDS
|
||||
while (remaining > 0) {
|
||||
LaunchedEffect(showing, secondsLeft == null) {
|
||||
if (!showing || secondsLeft != null) return@LaunchedEffect
|
||||
counted = UP_NEXT_SECONDS
|
||||
while (counted > 0) {
|
||||
delay(1_000)
|
||||
remaining -= 1
|
||||
counted -= 1
|
||||
}
|
||||
onPlayNow()
|
||||
}
|
||||
@@ -1068,8 +1347,49 @@ private fun UpNextCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Watch till the end")
|
||||
if (secondsLeft != null) {
|
||||
QuietButton(onClick = { held = true; onStay() }, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Watch till the end")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the player says once an episode ends and nothing starts after it. */
|
||||
@Composable
|
||||
private fun EndCard(
|
||||
finished: String,
|
||||
show: String,
|
||||
next: EpisodeRecord?,
|
||||
onPlayNext: () -> Unit,
|
||||
onReplay: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
if (next == null) "That was the last episode of $show" else "Finished $finished",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (next != null) {
|
||||
AccentButton(onClick = onPlayNext) { Text("Play next") }
|
||||
} else {
|
||||
QuietButton(onClick = onReplay) { Text("Watch again") }
|
||||
}
|
||||
QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1111,12 +1431,14 @@ private fun CompactIcon(
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
|
||||
// 48dp: the smallest target Android's accessibility guidance allows, and
|
||||
// the size a thumb on a moving train actually hits.
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = description,
|
||||
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.narl.protonstream.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* The settings that are Android's alone.
|
||||
@@ -35,6 +36,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,10 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
@@ -46,6 +48,8 @@ import kotlinx.coroutines.isActive
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
/** A crawl is running, which the banner offers to stop. */
|
||||
val crawling: Boolean = false,
|
||||
val query: String = "",
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
/**
|
||||
@@ -54,6 +58,7 @@ data class AppUiState(
|
||||
* when the grid falls back to [titles] as they are.
|
||||
*/
|
||||
val arrangement: ArrangementRecord? = null,
|
||||
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||
val sort: LibrarySort = LibrarySort.NAME,
|
||||
val grouped: Boolean = true,
|
||||
val shares: List<ShareRecord> = emptyList(),
|
||||
@@ -117,13 +122,33 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
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) {
|
||||
mutableState.update {
|
||||
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() } }
|
||||
@@ -152,10 +177,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder the grid, or fold or unfold its franchises. */
|
||||
/** Sort and fold the library, and keep both for the next launch. */
|
||||
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||
viewModelScope.launch { reloadLibrary(mutableState.value.query) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).apply {
|
||||
librarySort = sort.name
|
||||
libraryGrouped = grouped
|
||||
}
|
||||
}
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow the grid to some titles, and keep the choice for the next launch. */
|
||||
fun filter(filter: LibraryFilter) {
|
||||
mutableState.update { it.copy(filter = filter) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
@@ -167,7 +209,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
viewModelScope.launch {
|
||||
busy("Refreshing the library…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||
runCatching { crawlShares(null) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
@@ -205,6 +247,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
@@ -223,7 +286,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||
busy("Reading $name…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
@@ -289,15 +352,19 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
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 { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { failed += "$name: ${it.message}" }
|
||||
}
|
||||
}
|
||||
reloadNow()
|
||||
if (failed.isNotEmpty()) {
|
||||
// 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
|
||||
}
|
||||
@@ -305,13 +372,19 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl shares the catalog has nothing of: brought by a sync that ran in
|
||||
* [io.narl.protonstream.sync.WatchSyncWorker], or an add whose crawl never
|
||||
* finished.
|
||||
* 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
|
||||
@@ -350,6 +423,15 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeShare(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
@@ -543,11 +625,17 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||
val episodes = title.seasons.flatMap { it.episodes }
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||
|
||||
/**
|
||||
* Some episodes seen or unseen — a season, or a whole title — with an
|
||||
* Undo; [what] names them in the message.
|
||||
*/
|
||||
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
|
||||
changeWatch(
|
||||
episodes,
|
||||
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
|
||||
episodes.filter { it.watched != watched },
|
||||
"$what marked ${if (watched) "watched" else "unwatched"}",
|
||||
) { engine, episode, before ->
|
||||
val duration = before?.durationSecs
|
||||
engine.saveWatchState(
|
||||
@@ -595,14 +683,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
snapshots.forEach { snapshot ->
|
||||
val state = snapshot.state
|
||||
engine.saveWatchState(
|
||||
snapshot.shareId,
|
||||
snapshot.linkId,
|
||||
state?.positionSecs ?: 0.0,
|
||||
state?.durationSecs,
|
||||
state?.watched ?: false,
|
||||
)
|
||||
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||
}
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
@@ -739,24 +820,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* at its duration where one is known, so the progress bar agrees with the
|
||||
* tick.
|
||||
*/
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
||||
engine.saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
if (watched) duration ?: 0.0 else 0.0,
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
/** One episode's tick, with the same Undo a season's has. */
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||
|
||||
fun removeAllOffline() {
|
||||
viewModelScope.launch {
|
||||
@@ -800,7 +865,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(query),
|
||||
engine.arrangement(query, current.sort, current.grouped),
|
||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
@@ -828,7 +893,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val search = query.takeIf(String::isNotBlank)
|
||||
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
|
||||
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
|
||||
}
|
||||
}.onSuccess { (titles, arrangement) ->
|
||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -15,11 +18,22 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
@@ -93,21 +107,33 @@ internal fun Modifier.sharedArt(key: String): Modifier {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EmptyState(title: String, body: String) {
|
||||
internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Play on a title lands: the most recently played part-watched episode,
|
||||
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||
* Where Play on a title lands: what Continue watching offers
|
||||
* ([TitleRecord.continueIndex]), else the most recently played part-watched
|
||||
* episode, else the first unwatched one, else the first — the desktop's
|
||||
* `next_up`.
|
||||
*/
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||
continueIndex?.toInt()?.takeIf { it in playlist.indices }
|
||||
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
@@ -122,11 +148,52 @@ internal fun formatBytes(bytes: ULong): String {
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||
* Download episodes, asking first when it is more than one: a season or a
|
||||
* show is tens of gigabytes, and one tap should not fill a phone. The
|
||||
* question says how many and how big. Call it in composition; it draws the
|
||||
* dialog where it is called and returns what starts the download.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
@Composable
|
||||
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
|
||||
asking?.let { (episodes, what) ->
|
||||
val bytes = episodes.sumOf { it.size ?: 0uL }
|
||||
AlertDialog(
|
||||
onDismissRequest = { asking = null },
|
||||
title = { Text("Download ${episodes.size} episodes of $what?") },
|
||||
text = {
|
||||
Text(
|
||||
"They are kept on this device for watching without a connection" +
|
||||
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
|
||||
". Delete them from Downloads.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
asking = null
|
||||
DownloadCoordinator.enqueue(context, episodes)
|
||||
}) { Text("Download") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
return remember(context) {
|
||||
{ episodes, what ->
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline)
|
||||
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
|
||||
* [waitingFor], what it is really waiting on.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
|
||||
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
|
||||
?: download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
@@ -135,6 +202,25 @@ internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotN
|
||||
?.let { "${formatBytes(it.toULong())}/s" },
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* Why a queued download is not running, when it is not merely a free slot:
|
||||
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
|
||||
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
|
||||
* changes — the download's own updates are the clock.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberQueueReason(key: Any?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(key) {
|
||||
val network = context.getSystemService(ConnectivityManager::class.java)
|
||||
when {
|
||||
network?.activeNetwork == null -> "Waiting for a connection"
|
||||
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an episode row is headed with: "1. Mother and Children".
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.narl.protonstream.ui
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -40,7 +42,7 @@ import androidx.work.WorkManager
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
@@ -58,6 +60,8 @@ internal fun DownloadsScreen(
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onDeletePartial: (RetainedDownload) -> Unit,
|
||||
padding: PaddingValues,
|
||||
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||
onOpen: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||
@@ -122,7 +126,7 @@ internal fun DownloadsScreen(
|
||||
)
|
||||
}
|
||||
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||
SavedRow(file, group.title, onDelete = { deleting = file })
|
||||
SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +142,7 @@ internal fun DownloadsScreen(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
DangerButton(onClick = {
|
||||
deleting = null
|
||||
onRemove(file)
|
||||
}) { Text("Delete") }
|
||||
@@ -160,9 +164,23 @@ private fun SectionHeading(title: String, caption: String? = null) {
|
||||
|
||||
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||
@Composable
|
||||
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
|
||||
private fun SavedRow(
|
||||
file: OfflineRecord,
|
||||
title: TitleRecord?,
|
||||
onDelete: () -> Unit,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
) {
|
||||
val episode = file.episode
|
||||
// What a download is for: tapping it plays it, from where it was left,
|
||||
// without going back through the library to find it.
|
||||
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
|
||||
ListItem(
|
||||
modifier = if (title != null && index >= 0) {
|
||||
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
leadingContent = {
|
||||
RemoteArtwork(
|
||||
episode?.stillUrl ?: title?.backdropUrl,
|
||||
@@ -179,8 +197,15 @@ private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> U
|
||||
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
Row {
|
||||
title?.let {
|
||||
IconButton(onClick = { onOpen(it) }) {
|
||||
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
@@ -204,7 +229,7 @@ private fun PartialRow(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(downloadStatusLine(download))
|
||||
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
|
||||
@@ -19,6 +19,10 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import java.time.LocalDate
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
@@ -35,8 +39,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -61,6 +63,7 @@ import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
@@ -70,6 +73,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -82,6 +88,7 @@ import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
@@ -96,11 +103,50 @@ import uniffi.pstr_android.TitleType
|
||||
private const val CONTINUE_WATCHING_MAX = 12
|
||||
|
||||
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||
private data class Resumable(
|
||||
val title: TitleRecord,
|
||||
val episode: EpisodeRecord,
|
||||
val index: Int,
|
||||
/** When the title was last played, which orders the shelf. */
|
||||
val lastPlayed: Long,
|
||||
)
|
||||
|
||||
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
|
||||
private data class Tile(val title: TitleRecord, val folded: Int)
|
||||
|
||||
/** The desktop's names for the filters, from `browse::Filter::label`. */
|
||||
internal fun LibraryFilter.label(): String = when (this) {
|
||||
LibraryFilter.ALL -> "All"
|
||||
LibraryFilter.SERIES -> "Series"
|
||||
LibraryFilter.FILMS -> "Films"
|
||||
LibraryFilter.WATCHING -> "Watching"
|
||||
LibraryFilter.UNWATCHED -> "Not started"
|
||||
LibraryFilter.DOWNLOADED -> "Downloaded"
|
||||
}
|
||||
|
||||
/** The offer of posters and synopses, and its two answers. */
|
||||
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun MetadataOfferCard(offer: MetadataOffer) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) {
|
||||
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
|
||||
Text("Add posters and synopses?", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Turning it on in Settings sends the titles in your library — not your files, and " +
|
||||
"nothing about what you have watched — to AniList or TMDB.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, end = 8.dp),
|
||||
)
|
||||
Row(Modifier.align(Alignment.End)) {
|
||||
QuietButton(onClick = offer.onDismiss) { Text("Not now") }
|
||||
QuietButton(onClick = offer.onOpenSettings) { Text("Open settings") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row above the grid: titles that share a director, a studio or a genre. */
|
||||
private data class Shelf(val label: String, val titles: List<TitleRecord>)
|
||||
|
||||
@@ -118,22 +164,31 @@ internal fun LibraryScreen(
|
||||
onMatchChanged: () -> Unit = {},
|
||||
onError: (Throwable) -> Unit = {},
|
||||
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||
onAddShare: () -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
) {
|
||||
val download = rememberDownload()
|
||||
val context = LocalContext.current
|
||||
// Posters and synopses, offered once there is a library to put them on
|
||||
// and never turned on unasked: switching them on sends title names out.
|
||||
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
|
||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||
// Most recently played first, one episode per show: a shelf that lists four
|
||||
// episodes of the same series is a shelf with room for nothing else.
|
||||
// What to go on with is the engine's call (`continueIndex`): the
|
||||
// part-watched episode, or the one after the episode finished last.
|
||||
val resumable = remember(state.titles) {
|
||||
state.titles.mapNotNull { title ->
|
||||
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
|
||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||
playlist.withIndex()
|
||||
.filter { it.value.resumeAt != null && !it.value.watched }
|
||||
.maxByOrNull { it.value.lastPlayed }
|
||||
?.let { Resumable(title, it.value, it.index) }
|
||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||
playlist.getOrNull(index)?.let { episode ->
|
||||
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
|
||||
}
|
||||
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||
}
|
||||
// Search is an icon until it is wanted: a permanent field above the
|
||||
// library spent a band of every visit on something used in few of them.
|
||||
@@ -179,9 +234,16 @@ internal fun LibraryScreen(
|
||||
"Nothing matches",
|
||||
"No title in the library is called “${state.query.trim()}”.",
|
||||
)
|
||||
// A share just added: say it is being looked through, rather
|
||||
// than offer to add the one that is already filling the page.
|
||||
state.titles.isEmpty() && state.activity != null -> EmptyState(
|
||||
"Looking through the share",
|
||||
"${state.activity} Its titles appear here once it has been listed.",
|
||||
)
|
||||
state.titles.isEmpty() -> EmptyState(
|
||||
"Your library is empty",
|
||||
"Add a Proton Drive public link under Shares, then refresh.",
|
||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||
action = "Add a share" to onAddShare,
|
||||
)
|
||||
else -> LibraryGrid(
|
||||
state,
|
||||
@@ -190,6 +252,15 @@ internal fun LibraryScreen(
|
||||
onTitle,
|
||||
onTitleMenu = { menuFor = it },
|
||||
onResumeMenu = { menuResume = it },
|
||||
onFilter = onFilter,
|
||||
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
|
||||
null
|
||||
} else {
|
||||
MetadataOffer(onOpenSettings) {
|
||||
offerDismissed = true
|
||||
SettingsStore(context).metadataOfferDismissed = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,10 +270,10 @@ internal fun LibraryScreen(
|
||||
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||
onResume(title, nextUpIndex(playlist))
|
||||
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
||||
}
|
||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
||||
MenuRow(
|
||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||
@@ -384,6 +455,8 @@ private fun LibraryGrid(
|
||||
onTitle: (TitleRecord) -> Unit,
|
||||
onTitleMenu: (TitleRecord) -> Unit,
|
||||
onResumeMenu: (Resumable) -> Unit,
|
||||
onFilter: (LibraryFilter) -> Unit = {},
|
||||
metadataOffer: MetadataOffer? = null,
|
||||
) {
|
||||
// The arrangement names titles by key; before it has arrived, the titles
|
||||
// as they came are the grid.
|
||||
@@ -410,6 +483,9 @@ private fun LibraryGrid(
|
||||
// opening the app wants is almost always the thing they were part way
|
||||
// through. Inside the grid rather than pinned over it, so the shelf
|
||||
// scrolls away with the rest.
|
||||
if (metadataOffer != null && state.query.isBlank()) {
|
||||
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
|
||||
}
|
||||
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||
featured?.let { title ->
|
||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||
@@ -441,7 +517,10 @@ private fun LibraryGrid(
|
||||
}
|
||||
}
|
||||
if (state.query.isBlank()) {
|
||||
shelves.forEach { shelf ->
|
||||
// Only over the whole library: under a filter, a shelf of titles
|
||||
// the grid below is hiding reads as the filter not working. The
|
||||
// bridge leaves them out too; this covers the frame before it.
|
||||
shelves.takeIf { state.filter == LibraryFilter.ALL }.orEmpty().forEach { shelf ->
|
||||
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
|
||||
@@ -462,9 +541,29 @@ private fun LibraryGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column {
|
||||
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(LibraryFilter.entries, key = { it.name }) { filter ->
|
||||
FilterChip(
|
||||
selected = filter == state.filter,
|
||||
onClick = { onFilter(filter) },
|
||||
label = { Text(filter.label()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tiles.isEmpty() && state.filter != LibraryFilter.ALL) {
|
||||
item(key = "filtered-out", span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(
|
||||
"Nothing in the library fits that filter.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,7 +666,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
|
||||
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (resume != null) "Resume" else "Play")
|
||||
@@ -585,14 +684,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
||||
Modifier
|
||||
.width(220.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this episode"),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||
// Named by the text under it; read as well, the name came twice.
|
||||
RemoteArtwork(
|
||||
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||
entry.title.displayName,
|
||||
Modifier.fillMaxSize(),
|
||||
Modifier.fillMaxSize().clearAndSetSemantics {},
|
||||
fallback = entry.episode.thumbnailSource,
|
||||
maxEdge = 220.dp,
|
||||
)
|
||||
entry.episode.progress?.let { progress ->
|
||||
AccentProgress(
|
||||
@@ -609,7 +710,11 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||
if (entry.episode.resumeAt == null) {
|
||||
"Next: ${entry.episode.label}"
|
||||
} else {
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ")
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
@@ -638,7 +743,7 @@ private fun PosterTile(
|
||||
Column(
|
||||
modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
|
||||
) {
|
||||
Box {
|
||||
RemoteArtwork(
|
||||
@@ -647,8 +752,11 @@ private fun PosterTile(
|
||||
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clearAndSetSemantics {},
|
||||
fallback = title.thumbnailSource,
|
||||
// The tile's height, at the widest a grid column gets.
|
||||
maxEdge = 280.dp,
|
||||
)
|
||||
if (folded > 0) {
|
||||
Text(
|
||||
@@ -660,7 +768,8 @@ private fun PosterTile(
|
||||
.padding(6.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "and $folded more" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.work.WorkManager
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
@@ -133,6 +134,7 @@ fun ProtonStreamApp(
|
||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||
// into the video they had just left.
|
||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||
var addingShare by remember { mutableStateOf(false) }
|
||||
|
||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||
val playing = playingTitleKey
|
||||
@@ -226,6 +228,12 @@ fun ProtonStreamApp(
|
||||
onMatchChanged = model::reloadAfterMetadataChange,
|
||||
onError = model::reportError,
|
||||
onArrange = model::arrange,
|
||||
onAddShare = {
|
||||
destination = Destination.Shares
|
||||
addingShare = true
|
||||
},
|
||||
onOpenSettings = { destination = Destination.Settings },
|
||||
onFilter = model::filter,
|
||||
)
|
||||
}
|
||||
val titlePane = @Composable { selected: TitleRecord ->
|
||||
@@ -242,6 +250,7 @@ fun ProtonStreamApp(
|
||||
model::reloadAfterMetadataChange,
|
||||
model::setWatched,
|
||||
body,
|
||||
onMarkEpisodes = model::markEpisodes,
|
||||
franchise = selected.franchise.mapNotNull { key ->
|
||||
state.titles.firstOrNull { it.key == key }
|
||||
},
|
||||
@@ -273,12 +282,21 @@ fun ProtonStreamApp(
|
||||
}
|
||||
Destination.Shares -> SharesScreen(
|
||||
state.shares,
|
||||
model::addShare,
|
||||
{ name, url, password ->
|
||||
// The first share is the library: watch it fill
|
||||
// rather than stay on a form that has done its job.
|
||||
val first = state.titles.isEmpty()
|
||||
model.addShare(name, url, password).also { refusal ->
|
||||
if (refusal == null && first) destination = Destination.Library
|
||||
}
|
||||
},
|
||||
model::repairShare,
|
||||
model::refreshShare,
|
||||
model::removeShare,
|
||||
shareLink,
|
||||
onShareLinkTaken,
|
||||
// The empty library's button opens the form as a
|
||||
// pasted link would, only with nothing in it.
|
||||
shareLink ?: "".takeIf { addingShare },
|
||||
{ addingShare = false; onShareLinkTaken() },
|
||||
body,
|
||||
account = state.account,
|
||||
accountActions = AccountActions(
|
||||
@@ -292,6 +310,7 @@ fun ProtonStreamApp(
|
||||
addFolder = model::addAccountFolder,
|
||||
onError = model::reportError,
|
||||
),
|
||||
onRename = model::renameShare,
|
||||
)
|
||||
Destination.History -> HistoryScreen(
|
||||
state,
|
||||
@@ -311,6 +330,15 @@ fun ProtonStreamApp(
|
||||
model::resumeDownload,
|
||||
model::deletePartial,
|
||||
body,
|
||||
onPlay = { title, index ->
|
||||
playingTitleKey = title.key
|
||||
playingIndex = index
|
||||
playerMinimized = false
|
||||
},
|
||||
onOpen = { title ->
|
||||
destination = Destination.Library
|
||||
selectedTitleKey = title.key
|
||||
},
|
||||
)
|
||||
Destination.Settings -> SettingsScreen(
|
||||
state,
|
||||
@@ -339,7 +367,8 @@ fun ProtonStreamApp(
|
||||
}
|
||||
ActivityBanner(
|
||||
state.activity,
|
||||
Modifier
|
||||
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||
)
|
||||
@@ -421,7 +450,7 @@ private fun MiniTransport(
|
||||
* Not dismissible and not timed — it goes when the work does.
|
||||
*/
|
||||
@Composable
|
||||
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
||||
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = activity != null,
|
||||
modifier = modifier,
|
||||
@@ -442,7 +471,10 @@ private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2)
|
||||
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
|
||||
// A crawl of a large share can take minutes, and is the viewer's
|
||||
// to call off; what it has read so far stays.
|
||||
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -87,16 +88,20 @@ internal fun RemoteArtwork(
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: ThumbnailSource? = null,
|
||||
labelled: Boolean = true,
|
||||
maxEdge: Dp? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||
// and decoding its poster at 1024 holds four times the memory for nothing.
|
||||
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
||||
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||
LaunchedEffect(url, fallback, enabled) {
|
||||
if (!enabled) {
|
||||
image = ArtworkLoad.Missing
|
||||
return@LaunchedEffect
|
||||
}
|
||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
|
||||
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||
?: ArtworkLoad.Missing
|
||||
}
|
||||
@@ -186,8 +191,8 @@ internal fun initials(name: String): String {
|
||||
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||
* shows costs no second download.
|
||||
*/
|
||||
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
||||
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
||||
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val uri = URI(requested)
|
||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||
@@ -197,7 +202,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
val cached = directory.resolve("$digest.img")
|
||||
val fresh = !cached.isFile
|
||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
|
||||
val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" }
|
||||
if (fresh) {
|
||||
// Written only once it has decoded. Caching the bytes first is
|
||||
// what turns one hostile poster into a crash on every launch
|
||||
@@ -210,7 +215,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
}
|
||||
bitmap
|
||||
}.getOrNull()?.also { decoded.put(requested, it) }
|
||||
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,21 +229,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
* share or a session that has to be reopened is transient, and the next
|
||||
* composition should try again.
|
||||
*/
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
|
||||
val file = source ?: return null
|
||||
val key = file.key
|
||||
decoded.get(key)?.let { return it }
|
||||
decoded.get(sized(key, edge))?.let { return it }
|
||||
if (key in missing) return null
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||
}
|
||||
val bytes = fetched.getOrElse { return null }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
|
||||
if (bitmap == null) {
|
||||
missing.add(key)
|
||||
return null
|
||||
}
|
||||
decoded.put(key, bitmap)
|
||||
decoded.put(sized(key, edge), bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@@ -257,28 +262,43 @@ private fun download(uri: URI): ByteArray {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode within a bound the display can actually use.
|
||||
* Decode with its longest edge at most [edge] pixels.
|
||||
*
|
||||
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||
* costs no allocation and settles a sample size before anything is committed.
|
||||
*
|
||||
* Sampling halves, so it stops while the image is still at least [edge]; the
|
||||
* decoder scales the rest of the way, which a poster sampled straight to under
|
||||
* its tile would show as blur.
|
||||
*/
|
||||
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
||||
private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (longest <= 0) return null
|
||||
var sample = 1
|
||||
while (longest / sample > MAX_ART_EDGE) sample *= 2
|
||||
while (longest / (sample * 2) >= edge) sample *= 2
|
||||
val sampled = longest / sample
|
||||
return BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
if (sampled > edge) {
|
||||
inScaled = true
|
||||
inDensity = sampled
|
||||
inTargetDensity = edge
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The decoded cache's key for one image at one size. */
|
||||
private fun sized(key: String, edge: Int) = "$key#$edge"
|
||||
|
||||
/**
|
||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||
*
|
||||
@@ -315,7 +335,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
|
||||
* is a full-resolution decode per item.
|
||||
*/
|
||||
private val decoded = object : LruCache<String, Bitmap>(
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
|
||||
) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -103,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
|
||||
@@ -206,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) {
|
||||
@@ -316,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") } },
|
||||
)
|
||||
@@ -419,6 +431,7 @@ private fun MetadataSettingsDialog(
|
||||
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 = { if (!saving) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
@@ -450,6 +463,16 @@ private fun MetadataSettingsDialog(
|
||||
)
|
||||
}
|
||||
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) }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,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
|
||||
@@ -50,6 +54,8 @@ 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
|
||||
@@ -65,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) }
|
||||
@@ -103,6 +111,7 @@ internal fun SharesScreen(
|
||||
onRefresh = { onRefresh(share.id) },
|
||||
onRepair = { repairing = share },
|
||||
onRemove = { removing = share },
|
||||
onRename = { renaming = share },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,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`).
|
||||
@@ -154,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") } },
|
||||
)
|
||||
@@ -167,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(
|
||||
@@ -206,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.
|
||||
@@ -296,6 +359,7 @@ private fun AddShareDialog(
|
||||
) {
|
||||
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) }
|
||||
@@ -306,17 +370,31 @@ private fun AddShareDialog(
|
||||
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,
|
||||
@@ -347,7 +425,7 @@ private fun AddShareDialog(
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = name.isNotBlank() && url.isNotBlank() && !adding,
|
||||
enabled = url.isNotBlank() && !adding,
|
||||
) { Text(if (adding) "Adding…" else "Add") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -30,6 +31,8 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,14 +41,17 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.RemoveDone
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -68,6 +74,7 @@ import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AccentProgress
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.QuietButton
|
||||
@@ -97,11 +104,14 @@ internal fun TitleScreen(
|
||||
onMetadataChanged: () -> Unit,
|
||||
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||
padding: PaddingValues,
|
||||
/** Several episodes seen or unseen at once, named for the message. */
|
||||
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
|
||||
/** Every title of its franchise, in release order, this one included. */
|
||||
franchise: List<TitleRecord> = emptyList(),
|
||||
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val download = rememberDownload()
|
||||
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||
|
||||
@@ -122,7 +132,7 @@ internal fun TitleScreen(
|
||||
// Display order across seasons: what previous/next and autoplay walk, and
|
||||
// the same order the desktop client uses.
|
||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||
val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
|
||||
val upNext = playlist.getOrNull(nextUp)
|
||||
// One season on screen at a time, opened on the one Play would land in: a
|
||||
// forty-episode show as stacked sections is one long scroll to the part
|
||||
@@ -138,7 +148,11 @@ internal fun TitleScreen(
|
||||
// without a trip to the Downloads tab.
|
||||
val work by WorkManager.getInstance(context)
|
||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||
// Decoded off the main thread: WorkManager reports progress several times
|
||||
// a second while a season downloads, and each report re-reads every record.
|
||||
val downloads by produceState(emptyList<RetainedDownload>(), work) {
|
||||
value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() }
|
||||
}
|
||||
|
||||
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||
// button and synopsis lines two thousand pixels wide are read by turning
|
||||
@@ -266,7 +280,7 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||
DownloadCoordinator.enqueue(context, playlist)
|
||||
download(playlist, title.displayName)
|
||||
}
|
||||
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes
|
||||
@@ -343,7 +357,9 @@ internal fun TitleScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||
// By position: two seasons can share a label, and a
|
||||
// repeated key is a crash in a lazy list.
|
||||
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
@@ -352,7 +368,16 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
season?.let {
|
||||
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||
// For someone joining the show partway, who would
|
||||
// otherwise tick every earlier episode one by one.
|
||||
val seen = it.episodes.all(EpisodeRecord::watched)
|
||||
IconButton(onClick = { onMarkEpisodes(it.episodes, !seen, "${title.displayName} ${it.label}") }) {
|
||||
Icon(
|
||||
if (seen) Icons.Default.RemoveDone else Icons.Default.DoneAll,
|
||||
contentDescription = "Mark ${it.label} ${if (seen) "unwatched" else "watched"}",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { download(it.episodes, "${title.displayName} ${it.label}") }) {
|
||||
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||
}
|
||||
}
|
||||
@@ -374,6 +399,14 @@ internal fun TitleScreen(
|
||||
onPause = { DownloadCoordinator.pause(context, it) },
|
||||
onResume = { DownloadCoordinator.resume(context, it) },
|
||||
onSetWatched = { onSetWatched(episode, it) },
|
||||
onWatchedUpTo = {
|
||||
val upTo = playlist.indexOfFirst { it.linkId == episode.linkId }
|
||||
onMarkEpisodes(
|
||||
playlist.take(upTo + 1),
|
||||
true,
|
||||
"${title.displayName} up to ${episode.label}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -521,16 +554,35 @@ private fun EpisodeRow(
|
||||
onPause: (RetainedDownload) -> Unit,
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onSetWatched: (Boolean) -> Unit,
|
||||
onWatchedUpTo: () -> Unit = {},
|
||||
) {
|
||||
// A long press, for someone joining a show partway: everything before
|
||||
// this one ticked at once rather than one by one.
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||
download?.status == RetainedDownload.STATUS_QUEUED
|
||||
val heading = episode.heading(position)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = playerReady, onClick = onPlay)
|
||||
.combinedClickable(
|
||||
enabled = playerReady,
|
||||
onClick = onPlay,
|
||||
onLongClick = { menu = true },
|
||||
onLongClickLabel = "More for this episode",
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Mark watched up to here") },
|
||||
leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) },
|
||||
onClick = {
|
||||
menu = false
|
||||
onWatchedUpTo()
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// With metadata off the provider has no still, and Proton's own
|
||||
// thumbnail is a frame of this very episode. Where it was left sits
|
||||
@@ -636,7 +688,7 @@ private fun EpisodeRow(
|
||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||
)
|
||||
Text(
|
||||
downloadStatusLine(active),
|
||||
downloadStatusLine(active, rememberQueueReason(active)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -656,7 +708,33 @@ internal fun ChangeMatchDialog(
|
||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||
var confirmForget by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
if (confirmForget) {
|
||||
// Asked, because a match chosen by hand is work the next look-up
|
||||
// does not redo: it decides again on its own.
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmForget = false },
|
||||
title = { Text("Forget this match?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its poster, description and episode names go, and the next look-up " +
|
||||
"matches it again on its own. A match you chose by hand is lost.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(onClick = {
|
||||
confirmForget = false
|
||||
scope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||
.onSuccess { onChanged() }.onFailure(onError)
|
||||
}
|
||||
}) { Text("Forget") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } },
|
||||
)
|
||||
return
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Change match") },
|
||||
@@ -705,12 +783,7 @@ internal fun ChangeMatchDialog(
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
QuietButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||
.onSuccess { onChanged() }.onFailure(onError)
|
||||
}
|
||||
}) { Text("Forget match") }
|
||||
QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
|
||||
@@ -142,6 +142,28 @@ internal fun QuietButton(
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* The yes of a confirmation that deletes or forgets something.
|
||||
*
|
||||
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
|
||||
* whose most inviting button is the one that loses something reads as the
|
||||
* safe choice. Error-toned text instead, as Material's own guidance has it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DangerButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far along something is, drawn in the accent.
|
||||
*
|
||||
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -26,6 +26,7 @@ class HistoryTest {
|
||||
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
continueIndex = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
@@ -79,18 +81,23 @@ class ScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun `the library on a phone`() = capture("library-phone") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the library on a tablet`() = capture("library-tablet") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,7 +123,7 @@ class ScreenshotTest {
|
||||
// reads it from, so the scale is set there.
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +271,9 @@ class ScreenshotTest {
|
||||
nextEpisode = null,
|
||||
nextAiringAt = null,
|
||||
franchise = emptyList(),
|
||||
// What the engine would pick for these fixtures: the part-watched episode.
|
||||
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
|
||||
.takeIf { it >= 0 }?.toUInt(),
|
||||
)
|
||||
|
||||
val SERIES = title(
|
||||
@@ -290,6 +300,16 @@ class ScreenshotTest {
|
||||
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||
.copy(kind = TitleType.FILM, year = 1988u)
|
||||
|
||||
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
|
||||
val MATCHED get() = LIBRARY.copy(
|
||||
metadataSettings = MetadataSettingsRecord(
|
||||
enabled = true,
|
||||
provider = MetadataProvider.ANI_LIST,
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
)
|
||||
|
||||
val LIBRARY = AppUiState(
|
||||
loading = false,
|
||||
titles = listOf(
|
||||
@@ -306,9 +326,17 @@ class ScreenshotTest {
|
||||
},
|
||||
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
||||
),
|
||||
// Crawled relative to the clock, so the row reads "2 h ago" on
|
||||
// whatever day the test runs.
|
||||
shares = listOf(
|
||||
ShareRecord("anime", "anime", true),
|
||||
ShareRecord("films", "films", false),
|
||||
ShareRecord(
|
||||
"anime",
|
||||
"anime",
|
||||
true,
|
||||
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
|
||||
crawlFiles = 120uL,
|
||||
),
|
||||
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
|
||||
),
|
||||
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 631 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 669 KiB After Width: | Height: | Size: 670 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -4,7 +4,7 @@
|
||||
//! than the catalog's internal Rust types. Kotlin owns lifecycle and drawing;
|
||||
//! Rust remains the sole owner of Proton sessions, SQLite and decrypted bytes.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -12,8 +12,10 @@ use std::sync::{Arc, OnceLock};
|
||||
use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||
use pstr_core::browse::Sort;
|
||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||
use pstr_core::browse::{Filter, Sort};
|
||||
use pstr_core::catalog::{
|
||||
Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
|
||||
};
|
||||
use pstr_core::chapters::{Chapter, ChapterRole};
|
||||
use pstr_core::config::AppDirs;
|
||||
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||
@@ -25,6 +27,7 @@ use pstr_core::proton_drive_rs::ThumbnailType;
|
||||
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
|
||||
use pstr_core::shares::AccountFolder;
|
||||
use pstr_core::subtitles::SubtitleTrack;
|
||||
use pstr_core::sync::WatchSync;
|
||||
use pstr_core::{
|
||||
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
|
||||
@@ -102,6 +105,36 @@ pub struct AndroidPaths {
|
||||
pub cache: String,
|
||||
}
|
||||
|
||||
/// A subtitle file beside an episode, opened and published for the native
|
||||
/// player like the episode itself. The player releases `handle` when mpv
|
||||
/// closes it; one never handed to mpv goes back through
|
||||
/// `pstr_android_stream_release`.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SubtitleFileRecord {
|
||||
pub handle: u64,
|
||||
/// What else the file name says — "forced", "SDH" — or "External".
|
||||
pub title: String,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
/// An episode's subtitle files, and which to turn on when the episode has
|
||||
/// none of its own playing.
|
||||
#[derive(Debug, Clone, Default, uniffi::Record)]
|
||||
pub struct SubtitleFilesRecord {
|
||||
pub files: Vec<SubtitleFileRecord>,
|
||||
pub preferred: Option<u32>,
|
||||
}
|
||||
|
||||
/// Episodes of one title that a background crawl found, for a notification.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NewEpisodesRecord {
|
||||
pub title_key: String,
|
||||
pub title: String,
|
||||
pub count: u32,
|
||||
/// The first new episode's label — "S01E04" — when there is one.
|
||||
pub first: Option<String>,
|
||||
}
|
||||
|
||||
/// Android implements this with an AES-GCM key held by Android Keystore.
|
||||
#[uniffi::export(callback_interface)]
|
||||
pub trait AndroidSecretStore: Send + Sync {
|
||||
@@ -140,6 +173,18 @@ pub struct ShareRecord {
|
||||
/// A folder of the signed-in account rather than a public link.
|
||||
#[uniffi(default = false)]
|
||||
pub from_account: bool,
|
||||
/// When the last crawl here finished, Unix seconds. `None` before one has.
|
||||
#[uniffi(default = None)]
|
||||
pub crawled_at: Option<i64>,
|
||||
/// Playable files that crawl stored.
|
||||
#[uniffi(default = None)]
|
||||
pub crawl_files: Option<u64>,
|
||||
/// Of those, how many no earlier crawl had seen.
|
||||
#[uniffi(default = None)]
|
||||
pub crawl_added: Option<u64>,
|
||||
/// Why the last attempt failed, until one succeeds.
|
||||
#[uniffi(default = None)]
|
||||
pub crawl_error: Option<String>,
|
||||
}
|
||||
|
||||
fn share_record(share: Share) -> ShareRecord {
|
||||
@@ -148,6 +193,10 @@ fn share_record(share: Share) -> ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
crawled_at: None,
|
||||
crawl_files: None,
|
||||
crawl_added: None,
|
||||
crawl_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +350,17 @@ pub enum TitleNamesChoice {
|
||||
Romaji,
|
||||
}
|
||||
|
||||
/// The shared `pstr_core::browse::Filter`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum LibraryFilter {
|
||||
All,
|
||||
Series,
|
||||
Films,
|
||||
Watching,
|
||||
Unwatched,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// The shared `pstr_core::browse::Sort`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum LibrarySort {
|
||||
@@ -509,6 +569,11 @@ pub struct TitleRecord {
|
||||
/// Every title of its franchise, in release order, its own key included;
|
||||
/// empty for a title that stands alone.
|
||||
pub franchise: Vec<String>,
|
||||
/// Where Continue watching picks this title up, as an index into its
|
||||
/// episodes in display order: the part-watched one, or the one after the
|
||||
/// episode finished last. `None` keeps it off the shelf. See
|
||||
/// `pstr_core::library::Title::continue_with`.
|
||||
pub continue_index: Option<u32>,
|
||||
}
|
||||
|
||||
/// Playback preferences that outlive one file, one title and one launch.
|
||||
@@ -675,6 +740,38 @@ fn chapter_kind(role: ChapterRole) -> ChapterKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// One subtitle track as the player read it out of mpv's `track-list`.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct SubtitleTrackRecord {
|
||||
pub id: i64,
|
||||
pub language: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub default: bool,
|
||||
pub forced: bool,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
/// The dialogue track to play in place of the subtitle track mpv chose, when
|
||||
/// mpv chose the signs and songs (`pstr_core::subtitles`). Asked once per file,
|
||||
/// as soon as mpv has loaded it and made its own pick.
|
||||
#[uniffi::export]
|
||||
pub fn full_subtitles_instead(tracks: Vec<SubtitleTrackRecord>) -> Option<i64> {
|
||||
let tracks: Vec<SubtitleTrack> = tracks
|
||||
.into_iter()
|
||||
.map(|track| SubtitleTrack {
|
||||
id: track.id,
|
||||
language: track
|
||||
.language
|
||||
.filter(|language| !language.trim().is_empty()),
|
||||
title: track.title.filter(|title| !title.trim().is_empty()),
|
||||
default: track.default,
|
||||
forced: track.forced,
|
||||
selected: track.selected,
|
||||
})
|
||||
.collect();
|
||||
pstr_core::subtitles::full_instead(&tracks)
|
||||
}
|
||||
|
||||
/// What the app is holding on disk. Offline episodes are the viewer's, the
|
||||
/// block cache is not: only the latter may be reclaimed without asking.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
@@ -942,6 +1039,8 @@ pub struct AndroidEngine {
|
||||
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
|
||||
/// The whole library as bridge records, behind the catalog's write counter.
|
||||
library_cache: Mutex<Option<CachedLibrary>>,
|
||||
/// Advanced by [`AndroidEngine::stop_crawl`]; a crawl watching it stops.
|
||||
crawl_stop: tokio::sync::watch::Sender<u64>,
|
||||
/// Serializes offline publication with share removal cleanup.
|
||||
share_publication: Mutex<()>,
|
||||
/// Advances after every successful share-store mutation. A connection is
|
||||
@@ -949,12 +1048,6 @@ pub struct AndroidEngine {
|
||||
share_generation: AtomicU64,
|
||||
/// How many Proton thumbnails may be in flight at once.
|
||||
thumbnails: tokio::sync::Semaphore,
|
||||
/// When each share's visitor session was last refreshed.
|
||||
///
|
||||
/// Sessions are short-lived and the app is routinely backgrounded for hours
|
||||
/// between one episode and the next, so age — not just a share-store
|
||||
/// mutation — is a reason to reauthenticate before opening anything.
|
||||
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
||||
/// The streaming cache's budget in bytes, for the next connection and,
|
||||
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||
cache_budget: AtomicU64,
|
||||
@@ -982,13 +1075,6 @@ const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
|
||||
/// How often a download waiting for a slot asks whether it was cancelled.
|
||||
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// How long a visitor session is assumed good for without a refresh.
|
||||
///
|
||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||
/// failure that, before this, the viewer could only clear with a manual
|
||||
/// pull-to-refresh.
|
||||
const SESSION_FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
#[uniffi::export]
|
||||
impl AndroidEngine {
|
||||
#[uniffi::constructor]
|
||||
@@ -1031,10 +1117,10 @@ impl AndroidEngine {
|
||||
connecting: tokio::sync::Mutex::new(()),
|
||||
reusable_clients: Mutex::new(None),
|
||||
library_cache: Mutex::new(None),
|
||||
crawl_stop: tokio::sync::watch::channel(0).0,
|
||||
share_publication: Mutex::new(()),
|
||||
share_generation: AtomicU64::new(0),
|
||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||
session_refreshed: Mutex::new(HashMap::new()),
|
||||
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
||||
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
|
||||
accounts,
|
||||
@@ -1268,31 +1354,95 @@ impl AndroidEngine {
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
/// Configured shares with nothing in the catalog: brought in by sync,
|
||||
/// possibly while the app was in the background, or added here and never
|
||||
/// crawled to the end. Crawl each. An empty share is crawled every time,
|
||||
/// which costs one listing.
|
||||
/// Configured shares due a crawl: never crawled here — brought in by sync,
|
||||
/// possibly while the app was in the background, or added and never
|
||||
/// crawled to the end — or not looked at for
|
||||
/// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`], so a share grows
|
||||
/// without anyone pressing Refresh. Crawl each.
|
||||
pub fn uncrawled_shares(&self) -> Result<Vec<String>, BridgeError> {
|
||||
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
||||
let catalog = self.catalog.lock();
|
||||
let mut uncrawled = Vec::new();
|
||||
for share in shares {
|
||||
if catalog
|
||||
.files(&share.id)
|
||||
.map_err(BridgeError::from_display)?
|
||||
.is_empty()
|
||||
{
|
||||
uncrawled.push(share.id);
|
||||
}
|
||||
}
|
||||
Ok(uncrawled)
|
||||
let crawls = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let now = now();
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.filter(|share| {
|
||||
crawls.get(&share.id).is_none_or(|record| {
|
||||
record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS)
|
||||
})
|
||||
})
|
||||
.map(|share| share.id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Crawl the shares due a look and say which titles gained episodes, for
|
||||
/// a notification from a background job.
|
||||
///
|
||||
/// Only shares crawled here before: the first crawl of one finds every
|
||||
/// file new, and "312 new episodes" is not news. A share that fails is
|
||||
/// skipped, so one revoked link does not hide another's episodes.
|
||||
pub async fn crawl_for_news(self: Arc<Self>) -> Result<Vec<NewEpisodesRecord>, BridgeError> {
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
spawned(&runtime, async move {
|
||||
let crawls = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let due: Vec<String> = self
|
||||
.uncrawled_shares()?
|
||||
.into_iter()
|
||||
.filter(|id| crawls.get(id).is_some_and(|record| record.crawled_at > 0))
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let since = now();
|
||||
for id in due {
|
||||
if let Err(error) = self.crawl_inner(Some(id)).await {
|
||||
log::debug!("background crawl failed: {error}");
|
||||
}
|
||||
}
|
||||
self.with_library(|cached| {
|
||||
let mut news = news_since(&cached.library, &cached.first_seen, since);
|
||||
// By the name the library shows, which may be the provider's.
|
||||
for item in &mut news {
|
||||
if let Some(record) = cached.titles.iter().find(|t| t.key == item.title_key) {
|
||||
item.title.clone_from(&record.display_name);
|
||||
}
|
||||
}
|
||||
news
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Every configured share, with what its last crawl here came to.
|
||||
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
|
||||
self.store
|
||||
.list()
|
||||
.map_err(BridgeError::from_display)
|
||||
.map(|shares| shares.into_iter().map(share_record).collect())
|
||||
let shares = self.store.list().map_err(BridgeError::from_display)?;
|
||||
let crawls = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.map(|share| {
|
||||
let crawl = crawls.get(&share.id).cloned();
|
||||
let mut record = share_record(share);
|
||||
if let Some(crawl) = crawl {
|
||||
record.crawled_at = (crawl.crawled_at > 0).then_some(crawl.crawled_at);
|
||||
record.crawl_files = (crawl.crawled_at > 0).then_some(crawl.files);
|
||||
record.crawl_added = (crawl.crawled_at > 0).then_some(crawl.added);
|
||||
record.crawl_error = crawl.error;
|
||||
}
|
||||
record
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Add a public link, once it has opened with the password given. A link
|
||||
@@ -1344,6 +1494,14 @@ impl AndroidEngine {
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
/// Call a share something else; see `ShareStore::rename`.
|
||||
pub fn rename_share(&self, share_id: String, name: String) -> Result<ShareRecord, BridgeError> {
|
||||
self.store
|
||||
.rename(&share_id, &name)
|
||||
.map(share_record)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
|
||||
let _publication = self.share_publication.lock();
|
||||
let catalog = self.catalog.lock();
|
||||
@@ -1429,19 +1587,31 @@ impl AndroidEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// The library grid for `search`, in `sort` order, each franchise folded
|
||||
/// into one tile when `grouped` — and the shelves above it. Keys into
|
||||
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
|
||||
/// once.
|
||||
/// The library grid for `search`, narrowed by `filter`, in `sort` order,
|
||||
/// each franchise folded into one tile when `grouped` — and the shelves
|
||||
/// above it. Keys into [`AndroidEngine::library`]'s titles, so the records
|
||||
/// cross the bridge once.
|
||||
pub fn arrangement(
|
||||
&self,
|
||||
search: Option<String>,
|
||||
filter: LibraryFilter,
|
||||
sort: LibrarySort,
|
||||
grouped: bool,
|
||||
) -> Result<ArrangementRecord, BridgeError> {
|
||||
let query = search.unwrap_or_default();
|
||||
let filter = filter_choice(filter);
|
||||
self.with_library(|cached| {
|
||||
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query);
|
||||
let titles: Vec<&Title> =
|
||||
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
||||
.into_iter()
|
||||
.filter(|title| {
|
||||
filter.admits(title, |node| {
|
||||
cached
|
||||
.offline
|
||||
.contains(&(node.share_id.clone(), node.link_id.clone()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let tiles = pstr_core::browse::arrange(
|
||||
&titles,
|
||||
pstr_core::browse::Order {
|
||||
@@ -1452,7 +1622,9 @@ impl AndroidEngine {
|
||||
added: &cached.added,
|
||||
},
|
||||
);
|
||||
let shelves = if query.trim().is_empty() {
|
||||
// Shelves are the whole library's, and only when it is shown
|
||||
// whole.
|
||||
let shelves = if query.trim().is_empty() && filter == Filter::All {
|
||||
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
|
||||
.into_iter()
|
||||
.map(|shelf| ShelfRecord {
|
||||
@@ -1613,6 +1785,37 @@ impl AndroidEngine {
|
||||
.map(|state| state.map(watch_state_record))
|
||||
}
|
||||
|
||||
/// Put one file's watch state back exactly as [`Self::watch_state`] read
|
||||
/// it — what Undo does. Unlike [`Self::save_watch_state`] it keeps the
|
||||
/// original time, so an undone change sorts where it did before. `None`,
|
||||
/// a file that had never been played, becomes a reset older than anything,
|
||||
/// which every later state outranks.
|
||||
pub fn restore_watch_state(
|
||||
&self,
|
||||
share_id: String,
|
||||
link_id: String,
|
||||
state: Option<WatchStateRecord>,
|
||||
) -> Result<(), BridgeError> {
|
||||
let state = state.map_or(
|
||||
WatchState {
|
||||
position_secs: 0.0,
|
||||
duration_secs: None,
|
||||
watched: false,
|
||||
updated_at: 0,
|
||||
},
|
||||
|state| WatchState {
|
||||
position_secs: state.position_secs,
|
||||
duration_secs: state.duration_secs,
|
||||
watched: state.watched,
|
||||
updated_at: state.updated_at,
|
||||
},
|
||||
);
|
||||
self.catalog
|
||||
.lock()
|
||||
.set_watch_state(&share_id, &link_id, &state)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
pub fn save_watch_state(
|
||||
&self,
|
||||
share_id: String,
|
||||
@@ -1877,6 +2080,124 @@ impl AndroidEngine {
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Open the subtitle files paired with an episode, for the native player
|
||||
/// to add once it has the episode loaded. `language` is the subtitle
|
||||
/// language in effect for it, which picks [`SubtitleFilesRecord::preferred`].
|
||||
///
|
||||
/// Through the session already open: a downloaded episode played without
|
||||
/// one goes without its subtitles rather than waiting on a handshake. One
|
||||
/// that fails to open is left out; the episode plays either way.
|
||||
pub async fn open_subtitles(
|
||||
self: Arc<Self>,
|
||||
share_id: String,
|
||||
link_id: String,
|
||||
language: Option<String>,
|
||||
) -> SubtitleFilesRecord {
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
let opened = spawned(&runtime, async move {
|
||||
let sidecars = self
|
||||
.catalog
|
||||
.lock()
|
||||
.sidecars(&share_id, &link_id)
|
||||
.unwrap_or_default();
|
||||
if sidecars.is_empty() {
|
||||
return SubtitleFilesRecord::default();
|
||||
}
|
||||
let Some(connection) = self.cached_connection() else {
|
||||
return SubtitleFilesRecord::default();
|
||||
};
|
||||
let (engine, connection) = (&self, &connection);
|
||||
let opening = sidecars.into_iter().map(|sidecar| async move {
|
||||
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
|
||||
match engine
|
||||
.open_from_source(connection, &sidecar.share_id, &uid)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
stream.warm_edges();
|
||||
Some((sidecar, stream))
|
||||
}
|
||||
Err(error) => {
|
||||
log::debug!("opening subtitle {} failed: {error}", sidecar.name);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
let opened: Vec<_> = futures::future::join_all(opening)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let sidecars: Vec<_> = opened.iter().map(|(sidecar, _)| sidecar.clone()).collect();
|
||||
let preferred = pstr_core::catalog::preferred_sidecar(&sidecars, language.as_deref())
|
||||
.and_then(|index| u32::try_from(index).ok());
|
||||
let files = opened
|
||||
.into_iter()
|
||||
.map(|(sidecar, stream)| {
|
||||
let handle = Arc::new(AndroidStream {
|
||||
runtime: Arc::clone(&self.runtime),
|
||||
stream,
|
||||
cancel: tokio::sync::Notify::new(),
|
||||
native_id: Mutex::new(None),
|
||||
})
|
||||
.native_handle();
|
||||
SubtitleFileRecord {
|
||||
handle,
|
||||
title: sidecar.title.unwrap_or_else(|| "External".to_owned()),
|
||||
language: sidecar.language,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SubtitleFilesRecord { files, preferred }
|
||||
})
|
||||
.await;
|
||||
opened.unwrap_or_else(|error| {
|
||||
log::debug!("opening subtitles stopped: {error}");
|
||||
SubtitleFilesRecord::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch the blocks an episode opens with into the cache, without playing
|
||||
/// it: the next episode, near the end of this one, so that autoplay or
|
||||
/// "Play now" starts from the cache rather than from two round trips.
|
||||
///
|
||||
/// Quietly nothing for a downloaded episode, and on any failure: all a
|
||||
/// failure costs is the head start.
|
||||
pub async fn warm_episode(
|
||||
self: Arc<Self>,
|
||||
share_id: String,
|
||||
volume_id: String,
|
||||
link_id: String,
|
||||
) {
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
let warmed = spawned(&runtime, async move {
|
||||
let offline = self
|
||||
.catalog
|
||||
.lock()
|
||||
.offline_file(&share_id, &link_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if offline {
|
||||
return;
|
||||
}
|
||||
let Ok(connection) = self.connection().await else {
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.open_from_source(&connection, &share_id, &node_uid(&volume_id, &link_id))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => stream.warm_edges(),
|
||||
Err(error) => log::debug!("warming the next episode failed: {error}"),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = warmed {
|
||||
log::debug!("warming the next episode stopped: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_episode(
|
||||
self: Arc<Self>,
|
||||
share_id: String,
|
||||
@@ -1958,6 +2279,13 @@ impl AndroidEngine {
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
/// Stop every crawl running now. Shares already listed stay stored; the
|
||||
/// one being listed keeps what it had. The stopped `crawl` returns
|
||||
/// without an error, since whoever stopped it knows why.
|
||||
pub fn stop_crawl(&self) {
|
||||
self.crawl_stop.send_modify(|generation| *generation += 1);
|
||||
}
|
||||
}
|
||||
|
||||
impl AndroidEngine {
|
||||
@@ -2177,6 +2505,8 @@ impl AndroidEngine {
|
||||
metadata,
|
||||
added,
|
||||
titles,
|
||||
offline: offline.into_keys().collect(),
|
||||
first_seen,
|
||||
};
|
||||
let answer = read(&cached);
|
||||
*self.library_cache.lock() = Some(cached);
|
||||
@@ -2281,7 +2611,6 @@ impl AndroidEngine {
|
||||
}
|
||||
}
|
||||
drop(reusable);
|
||||
self.session_refreshed.lock().remove(share_id);
|
||||
self.share_generation.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
@@ -2445,48 +2774,31 @@ impl AndroidEngine {
|
||||
uid: &NodeUid,
|
||||
copy: bool,
|
||||
) -> Result<VideoStream, BridgeError> {
|
||||
let open = || async {
|
||||
if copy {
|
||||
connection.source.open_for_copy(share_id, uid).await
|
||||
} else {
|
||||
connection.source.open(share_id, uid).await
|
||||
}
|
||||
};
|
||||
let stale = self
|
||||
.session_refreshed
|
||||
.lock()
|
||||
.get(share_id)
|
||||
.is_none_or(|at| at.elapsed() >= SESSION_FRESH_FOR);
|
||||
if stale {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
}
|
||||
match open().await {
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(_) if !stale => {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
open().await.map_err(BridgeError::from_display)
|
||||
}
|
||||
Err(error) => Err(BridgeError::from_display(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: a refresh that fails leaves the open to report the real
|
||||
/// problem, which is more useful than a refresh error standing in for it.
|
||||
async fn refresh_session(&self, connection: &Connection, share_id: &str) {
|
||||
if connection.library.refresh_session(share_id).await.is_ok() {
|
||||
self.session_refreshed
|
||||
.lock()
|
||||
.insert(share_id.to_owned(), std::time::Instant::now());
|
||||
}
|
||||
connection
|
||||
.library
|
||||
.reauthenticating(share_id, || async {
|
||||
if copy {
|
||||
connection.source.open_for_copy(share_id, uid).await
|
||||
} else {
|
||||
connection.source.open(share_id, uid).await
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
|
||||
let mut stop = self.crawl_stop.subscribe();
|
||||
stop.borrow_and_update();
|
||||
let connection = self.connection().await?;
|
||||
let targets: Vec<String> = match share_id {
|
||||
Some(id) => vec![id],
|
||||
None => connection.library.share_ids().map(str::to_owned).collect(),
|
||||
};
|
||||
for id in targets {
|
||||
if stop.has_changed().unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
connection
|
||||
.library
|
||||
.refresh_session(&id)
|
||||
@@ -2494,24 +2806,39 @@ impl AndroidEngine {
|
||||
.map_err(|error| BridgeError::Failure {
|
||||
reason: format!("refresh session for {id}: {error}"),
|
||||
})?;
|
||||
self.session_refreshed
|
||||
.lock()
|
||||
.insert(id.clone(), std::time::Instant::now());
|
||||
let before = self
|
||||
.catalog
|
||||
.lock()
|
||||
.all_offline_files()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let nodes = connection
|
||||
.library
|
||||
.crawl(&id)
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let crawled = tokio::select! {
|
||||
crawled = connection.library.crawl(&id) => crawled,
|
||||
_ = stop.changed() => return Ok(()),
|
||||
};
|
||||
let nodes = match crawled {
|
||||
Ok(nodes) => nodes,
|
||||
Err(error) => {
|
||||
// On the share's row until a crawl succeeds: a link revoked
|
||||
// upstream otherwise just stops growing.
|
||||
if let Err(error) = self
|
||||
.catalog
|
||||
.lock()
|
||||
.record_crawl_failure(&id, &error.describe())
|
||||
{
|
||||
log::warn!("record a failed crawl: {error}");
|
||||
}
|
||||
return Err(BridgeError::from_display(error.describe()));
|
||||
}
|
||||
};
|
||||
let rows = build_rows(&id, &nodes);
|
||||
self.catalog
|
||||
.lock()
|
||||
.replace_share(&id, &rows)
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let sidecars = build_sidecars(&id, &nodes);
|
||||
{
|
||||
let mut catalog = self.catalog.lock();
|
||||
catalog
|
||||
.replace_share(&id, &rows)
|
||||
.and_then(|()| catalog.replace_sidecars(&id, &sidecars))
|
||||
.map_err(BridgeError::from_display)?;
|
||||
}
|
||||
let after = self
|
||||
.catalog
|
||||
.lock()
|
||||
@@ -2574,8 +2901,35 @@ impl AndroidEngine {
|
||||
}
|
||||
|
||||
let connection = self.connection().await?;
|
||||
self.open_from_source(&connection, share_id, &node_uid(volume_id, link_id))
|
||||
.await
|
||||
let stream = self
|
||||
.open_from_source(&connection, share_id, &node_uid(volume_id, link_id))
|
||||
.await?;
|
||||
// Before mpv has the handle: building its core and surface takes a
|
||||
// moment, and the network can spend that moment on the two reads
|
||||
// every file opens with — and, resuming, on the one it seeks to.
|
||||
match self.resume_fraction(share_id, link_id) {
|
||||
Some(fraction) => stream.warm_resume(fraction),
|
||||
None => stream.warm_edges(),
|
||||
}
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// How far into the file an episode resumes, as a fraction of its
|
||||
/// running time, from what was saved the last time it played. `None` for
|
||||
/// one that starts from the beginning, as the clients do for a finished
|
||||
/// episode or one barely begun.
|
||||
fn resume_fraction(&self, share_id: &str, link_id: &str) -> Option<f64> {
|
||||
let state = self
|
||||
.catalog
|
||||
.lock()
|
||||
.watch_state(share_id, link_id)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
if state.watched || state.position_secs < pstr_core::library::RESUME_FLOOR_SECS {
|
||||
return None;
|
||||
}
|
||||
let duration = state.duration_secs.filter(|duration| *duration > 0.0)?;
|
||||
Some(state.position_secs / duration)
|
||||
}
|
||||
|
||||
async fn download_episode_inner(
|
||||
@@ -3030,6 +3384,42 @@ struct CachedLibrary {
|
||||
/// When each title last gained a file.
|
||||
added: HashMap<String, i64>,
|
||||
titles: Vec<TitleRecord>,
|
||||
/// The files kept on this device, by share and link id.
|
||||
offline: HashSet<(String, String)>,
|
||||
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
||||
first_seen: HashMap<(String, String), i64>,
|
||||
}
|
||||
|
||||
/// The titles whose files first appeared at or after `since`, with how many
|
||||
/// and the first of them, most new episodes first.
|
||||
fn news_since(
|
||||
library: &Library,
|
||||
first_seen: &HashMap<(String, String), i64>,
|
||||
since: i64,
|
||||
) -> Vec<NewEpisodesRecord> {
|
||||
let mut news: Vec<NewEpisodesRecord> = library
|
||||
.titles
|
||||
.iter()
|
||||
.filter_map(|title| {
|
||||
let fresh: Vec<&pstr_core::library::Episode> = title
|
||||
.episodes()
|
||||
.filter(|episode| {
|
||||
first_seen
|
||||
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
|
||||
.is_some_and(|seen| *seen >= since)
|
||||
})
|
||||
.collect();
|
||||
let first = fresh.first()?;
|
||||
Some(NewEpisodesRecord {
|
||||
title_key: title.key.clone(),
|
||||
title: title.name.clone(),
|
||||
count: u32::try_from(fresh.len()).unwrap_or(u32::MAX),
|
||||
first: Some(first.label()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
news.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.title.cmp(&b.title)));
|
||||
news
|
||||
}
|
||||
|
||||
/// How many provider lookups may be in flight at once.
|
||||
@@ -3091,6 +3481,17 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_choice(filter: LibraryFilter) -> Filter {
|
||||
match filter {
|
||||
LibraryFilter::All => Filter::All,
|
||||
LibraryFilter::Series => Filter::Series,
|
||||
LibraryFilter::Films => Filter::Films,
|
||||
LibraryFilter::Watching => Filter::Watching,
|
||||
LibraryFilter::Unwatched => Filter::Unwatched,
|
||||
LibraryFilter::Downloaded => Filter::Downloaded,
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_choice(sort: LibrarySort) -> Sort {
|
||||
match sort {
|
||||
LibrarySort::Name => Sort::Name,
|
||||
@@ -3243,6 +3644,10 @@ fn title_record(
|
||||
next_episode: None,
|
||||
next_airing_at: None,
|
||||
franchise: Vec::new(),
|
||||
continue_index: title.continue_with().and_then(|episode| {
|
||||
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
|
||||
u32::try_from(at).ok()
|
||||
}),
|
||||
seasons: title
|
||||
.seasons
|
||||
.iter()
|
||||
@@ -3380,7 +3785,7 @@ mod tests {
|
||||
use super::{
|
||||
AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream,
|
||||
AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker,
|
||||
chapter_plan, match_record, node_uid, normalized_language, palette_record,
|
||||
chapter_plan, match_record, news_since, node_uid, normalized_language, palette_record,
|
||||
prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release,
|
||||
pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata,
|
||||
write_partial_marker,
|
||||
@@ -3838,7 +4243,12 @@ mod tests {
|
||||
}
|
||||
|
||||
let arranged = engine
|
||||
.arrangement(None, super::LibrarySort::Name, true)
|
||||
.arrangement(
|
||||
None,
|
||||
super::LibraryFilter::All,
|
||||
super::LibrarySort::Name,
|
||||
true,
|
||||
)
|
||||
.expect("arrange");
|
||||
assert_eq!(arranged.tiles.len(), 1);
|
||||
assert_eq!(arranged.tiles[0].key, "sousou no frieren");
|
||||
@@ -3930,6 +4340,54 @@ mod tests {
|
||||
drop(engine);
|
||||
std::fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn news_is_the_titles_that_gained_files_since_the_crawl_began_most_first() {
|
||||
let file = |link: &str, name: &str| pstr_core::catalog::CatalogNode {
|
||||
share_id: "s".into(),
|
||||
link_id: link.into(),
|
||||
volume_id: "v".into(),
|
||||
parent_link_id: None,
|
||||
name: name.into(),
|
||||
is_folder: false,
|
||||
media_type: None,
|
||||
size: None,
|
||||
active_revision_id: None,
|
||||
parsed: pstr_core::naming::parse(name),
|
||||
};
|
||||
let library = pstr_core::library::Library::build(
|
||||
vec![
|
||||
file("f1", "Frieren S01E01.mkv"),
|
||||
file("f2", "Frieren S01E02.mkv"),
|
||||
file("f3", "Frieren S01E03.mkv"),
|
||||
file("m1", "Mushishi S01E01.mkv"),
|
||||
file("m2", "Mushishi S01E02.mkv"),
|
||||
file("o1", "Monster S01E01.mkv"),
|
||||
],
|
||||
&HashMap::new(),
|
||||
);
|
||||
let seen = |link: &str, at: i64| (("s".to_string(), link.to_string()), at);
|
||||
let first_seen: HashMap<_, _> = [
|
||||
seen("f1", 10),
|
||||
seen("f2", 100),
|
||||
seen("f3", 100),
|
||||
seen("m1", 10),
|
||||
seen("m2", 100),
|
||||
seen("o1", 10),
|
||||
]
|
||||
.into();
|
||||
let news: Vec<(String, u32, Option<String>)> = news_since(&library, &first_seen, 100)
|
||||
.into_iter()
|
||||
.map(|news| (news.title, news.count, news.first))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
news,
|
||||
vec![
|
||||
("Frieren".to_string(), 2, Some("S01E02".to_string())),
|
||||
("Mushishi".to_string(), 1, Some("S01E02".to_string())),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -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()));
|
||||
|
||||
@@ -28,7 +28,7 @@ use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||
use pstr_core::appearance::Appearance;
|
||||
use pstr_core::catalog::{
|
||||
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
||||
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars,
|
||||
};
|
||||
use pstr_core::config::AppDirs;
|
||||
use pstr_core::library::{Library, Title, TitleKind};
|
||||
@@ -177,7 +177,11 @@ pub enum Event {
|
||||
/// The share the form asked for is stored; its crawl is starting.
|
||||
ShareAdded(String),
|
||||
/// The share the form asked for was refused, with why. Nothing was crawled.
|
||||
ShareRejected(String),
|
||||
ShareRejected {
|
||||
error: String,
|
||||
/// The link has a password of its own and none was given.
|
||||
needs_password: bool,
|
||||
},
|
||||
/// Shares opened; playback is possible from here.
|
||||
Connected {
|
||||
/// Shares that did not open, with why.
|
||||
@@ -193,8 +197,15 @@ pub enum Event {
|
||||
share_id: String,
|
||||
nodes: usize,
|
||||
files: usize,
|
||||
/// Files no earlier crawl had seen.
|
||||
added: u64,
|
||||
seconds: f64,
|
||||
/// Started by the app rather than the viewer, so only news is worth
|
||||
/// saying.
|
||||
quiet: bool,
|
||||
},
|
||||
/// What the last crawl of each share came to, by share id.
|
||||
Crawls(HashMap<String, pstr_core::catalog::CrawlRecord>),
|
||||
/// A crawl is under way: which share, and how many nodes so far.
|
||||
CrawlProgress { share: String, found: usize },
|
||||
/// Every requested crawl is done.
|
||||
@@ -298,6 +309,16 @@ pub enum Event {
|
||||
BrowseFailed(String),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// The add form's refusal of `error`.
|
||||
fn rejected(error: &pstr_core::Error) -> Self {
|
||||
Self::ShareRejected {
|
||||
error: error.to_string(),
|
||||
needs_password: matches!(error, pstr_core::Error::PasswordNeeded),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Proton account stands.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum AccountStatus {
|
||||
@@ -402,6 +423,14 @@ const DOWNLOAD_CONCURRENCY: usize = 3;
|
||||
/// launch and whenever a player stops.
|
||||
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Unix seconds, now.
|
||||
pub fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|since| since.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Enrichment as currently configured.
|
||||
struct Enrichment {
|
||||
config: MetadataConfig,
|
||||
@@ -656,14 +685,52 @@ impl Engine {
|
||||
.await
|
||||
{
|
||||
Ok((share, _)) => share,
|
||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||
Err(error) => return engine.emit(Event::rejected(&error)),
|
||||
};
|
||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||
engine.load_shares();
|
||||
// The open connection predates the share, and crawling it through
|
||||
// that would find it "not open".
|
||||
engine.connection.lock().take();
|
||||
engine.connect_and_crawl(Some(share.id)).await;
|
||||
engine.connect_and_crawl(Some(vec![share.id]), false).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Call a share something else. See [`ShareStore::rename`].
|
||||
pub fn rename_share(&self, id: String, name: String) {
|
||||
let engine = self.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || match engine.store.rename(&id, &name) {
|
||||
Ok(_) => engine.load_shares(),
|
||||
Err(error) => engine.fail("rename the share", error),
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-supply the link behind a share whose stored secret is unreadable —
|
||||
/// a keyring that was reset — or whose custom password changed, keeping
|
||||
/// its catalog, watch state and downloads. See
|
||||
/// [`ShareStore::replace_secrets`]. Then crawl it, which is what proves
|
||||
/// the new secret works.
|
||||
pub fn repair_share(&self, id: String, url: String, password: Option<String>) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let share = match engine.store.replace_secrets(&id, &url, password.as_deref()) {
|
||||
Ok(share) => share,
|
||||
Err(error) => {
|
||||
// The add form is not where this was asked, and nothing
|
||||
// will be crawled to say the crawl is over.
|
||||
engine.emit(Event::CrawlFinished);
|
||||
return engine.fail("update the link", error);
|
||||
}
|
||||
};
|
||||
engine.emit(Event::Status(format!(
|
||||
"updated the link for {}",
|
||||
share.name
|
||||
)));
|
||||
engine.load_shares();
|
||||
// Its client was opened with the old secret.
|
||||
engine.connection.lock().take();
|
||||
engine.connect_and_crawl(Some(vec![share.id]), false).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -677,12 +744,12 @@ impl Engine {
|
||||
};
|
||||
let share = match engine.store.add_folder(&name, folder) {
|
||||
Ok(share) => share,
|
||||
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
|
||||
Err(error) => return engine.emit(Event::rejected(&error)),
|
||||
};
|
||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||
engine.load_shares();
|
||||
engine.connection.lock().take();
|
||||
engine.connect_and_crawl(Some(share.id)).await;
|
||||
engine.connect_and_crawl(Some(vec![share.id]), false).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -944,7 +1011,7 @@ impl Engine {
|
||||
self.load_shares();
|
||||
self.load_library();
|
||||
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
|
||||
self.connect_and_crawl(Some(id.clone())).await;
|
||||
self.connect_and_crawl(Some(vec![id.clone()]), false).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,18 +1344,65 @@ impl Engine {
|
||||
pub fn crawl(&self, share: Option<String>) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
engine.connect_and_crawl(share).await;
|
||||
engine
|
||||
.connect_and_crawl(share.map(|id| vec![id]), false)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_and_crawl(&self, share: Option<String>) {
|
||||
/// Crawl, quietly, every share no crawl has looked at for
|
||||
/// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`] — or ever.
|
||||
///
|
||||
/// Without this a share only grew when someone thought to press Refresh,
|
||||
/// so the episode that came out yesterday was not in the library until
|
||||
/// then. Quiet: a failure is logged and recorded on the share's row rather
|
||||
/// than toasted, and a crawl that found nothing new says nothing.
|
||||
pub fn crawl_stale(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let stale = {
|
||||
let crawls = match engine.catalog.lock().crawls() {
|
||||
Ok(crawls) => crawls,
|
||||
Err(error) => return tracing::warn!(%error, "read crawl records"),
|
||||
};
|
||||
let Ok(shares) = engine.store.list() else {
|
||||
return;
|
||||
};
|
||||
let now = unix_now();
|
||||
shares
|
||||
.into_iter()
|
||||
.filter(|share| {
|
||||
crawls.get(&share.id).is_none_or(|record| {
|
||||
record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS)
|
||||
})
|
||||
})
|
||||
.map(|share| share.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
if !stale.is_empty() {
|
||||
engine.connect_and_crawl(Some(stale), true).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Read what the last crawl of each share came to, for the shares page.
|
||||
pub fn load_crawls(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || match engine.catalog.lock().crawls() {
|
||||
Ok(crawls) => engine.emit(Event::Crawls(crawls)),
|
||||
Err(error) => tracing::warn!(%error, "read crawl records"),
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_and_crawl(&self, shares: Option<Vec<String>>, quiet: bool) {
|
||||
let Some((library, _)) = self.ensure_connected().await else {
|
||||
self.emit(Event::CrawlFinished);
|
||||
return;
|
||||
};
|
||||
|
||||
let targets: Vec<String> = match share {
|
||||
Some(id) => vec![id],
|
||||
let targets: Vec<String> = match shares {
|
||||
Some(ids) => ids,
|
||||
None => library.share_ids().map(str::to_string).collect(),
|
||||
};
|
||||
|
||||
@@ -1298,7 +1412,7 @@ impl Engine {
|
||||
futures::future::join_all(
|
||||
targets
|
||||
.into_iter()
|
||||
.map(|share_id| self.crawl_share(&library, share_id, cancel.clone())),
|
||||
.map(|share_id| self.crawl_share(&library, share_id, cancel.clone(), quiet)),
|
||||
)
|
||||
.await;
|
||||
self.emit(Event::CrawlFinished);
|
||||
@@ -1342,6 +1456,7 @@ impl Engine {
|
||||
library: &SharedLibrary,
|
||||
share_id: String,
|
||||
mut cancel: tokio::sync::watch::Receiver<u64>,
|
||||
quiet: bool,
|
||||
) {
|
||||
let started = std::time::Instant::now();
|
||||
let name = self
|
||||
@@ -1371,10 +1486,24 @@ impl Engine {
|
||||
// share's rows and its stale downloads, and stopping halfway through
|
||||
// that would leave the two disagreeing.
|
||||
let nodes = tokio::select! {
|
||||
result = library.crawl_reporting(&share_id, report) => match result {
|
||||
result = library.reauthenticating(&share_id, || {
|
||||
library.crawl_reporting(&share_id, &report)
|
||||
}) => match result {
|
||||
Ok(nodes) => nodes,
|
||||
Err(error) => {
|
||||
self.fail(&format!("crawl {share_id}"), error);
|
||||
if let Err(error) = self
|
||||
.catalog
|
||||
.lock()
|
||||
.record_crawl_failure(&share_id, &error.describe())
|
||||
{
|
||||
tracing::warn!(%error, "record a failed crawl");
|
||||
}
|
||||
self.load_crawls();
|
||||
if quiet {
|
||||
tracing::warn!(%error, "crawl {name}");
|
||||
} else {
|
||||
self.fail(&format!("crawl {name}"), error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
@@ -1384,6 +1513,7 @@ impl Engine {
|
||||
}
|
||||
};
|
||||
let rows = build_rows(&share_id, &nodes);
|
||||
let sidecars = build_sidecars(&share_id, &nodes);
|
||||
let files = rows.len();
|
||||
|
||||
// A writer opened before the crawl may be producing the revision
|
||||
@@ -1445,7 +1575,7 @@ impl Engine {
|
||||
for (link_id, _) in &stale {
|
||||
catalog.remove_offline_file(&share_id, link_id)?;
|
||||
}
|
||||
Ok(())
|
||||
catalog.replace_sidecars(&share_id, &sidecars)
|
||||
})
|
||||
};
|
||||
if let Err(error) = stored {
|
||||
@@ -1453,12 +1583,22 @@ impl Engine {
|
||||
return;
|
||||
}
|
||||
|
||||
let added = self
|
||||
.catalog
|
||||
.lock()
|
||||
.crawls()
|
||||
.ok()
|
||||
.and_then(|crawls| crawls.get(&share_id).map(|record| record.added))
|
||||
.unwrap_or(0);
|
||||
self.emit(Event::Crawled {
|
||||
share_id,
|
||||
nodes: nodes.len(),
|
||||
files,
|
||||
added,
|
||||
seconds: started.elapsed().as_secs_f64(),
|
||||
quiet,
|
||||
});
|
||||
self.load_crawls();
|
||||
self.load_library();
|
||||
}
|
||||
|
||||
@@ -1549,19 +1689,26 @@ impl Engine {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some((_, source)) = engine.ensure_connected().await else {
|
||||
let Some((library, source)) = engine.ensure_connected().await else {
|
||||
return;
|
||||
};
|
||||
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||
match source.open(&target.share_id, &uid).await {
|
||||
let opened = library
|
||||
.reauthenticating(&target.share_id, || source.open(&target.share_id, &uid))
|
||||
.await;
|
||||
match opened {
|
||||
Ok(stream) if stream.size() == 0 => {
|
||||
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
||||
}
|
||||
Ok(stream) => {
|
||||
// Before mpv is even built: building it takes the UI thread
|
||||
// a moment, and the network can spend that moment on the
|
||||
// two reads every file opens with.
|
||||
stream.warm_edges();
|
||||
// two reads every file opens with — and, resuming, on the
|
||||
// one it is about to seek to.
|
||||
match engine.resume_fraction(&target) {
|
||||
Some(fraction) => stream.warm_resume(fraction),
|
||||
None => stream.warm_edges(),
|
||||
}
|
||||
engine.emit(Event::PlaybackReady {
|
||||
target: Box::new(target),
|
||||
stream,
|
||||
@@ -1572,6 +1719,69 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the subtitle files paired with `target`, for the player to add
|
||||
/// once mpv has the video.
|
||||
///
|
||||
/// Through the session already open: a local copy played without one goes
|
||||
/// without its subtitles rather than waiting on a handshake. Each is
|
||||
/// warmed — most are one block — so mpv reads it from the cache. One that
|
||||
/// fails is left out quietly; the video plays either way.
|
||||
pub async fn open_sidecars(
|
||||
&self,
|
||||
target: &PlaybackTarget,
|
||||
) -> Vec<(pstr_core::catalog::Sidecar, VideoStream)> {
|
||||
let sidecars = self
|
||||
.catalog
|
||||
.lock()
|
||||
.sidecars(&target.share_id, &target.link_id)
|
||||
.unwrap_or_default();
|
||||
if sidecars.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some((library, source)) = self.opened() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let opening = sidecars.into_iter().map(|sidecar| {
|
||||
let library = Arc::clone(&library);
|
||||
let source = source.clone();
|
||||
async move {
|
||||
let uid = node_uid(&sidecar.volume_id, &sidecar.link_id);
|
||||
match library
|
||||
.reauthenticating(&sidecar.share_id, || source.open(&sidecar.share_id, &uid))
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
stream.warm_edges();
|
||||
Some((sidecar, stream))
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "open subtitle {}", sidecar.name);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
futures::future::join_all(opening)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far into the file `target` resumes, as a fraction of its running
|
||||
/// time — known from the last time it played.
|
||||
fn resume_fraction(&self, target: &PlaybackTarget) -> Option<f64> {
|
||||
let at = target.resume_at?;
|
||||
let state = self
|
||||
.catalog
|
||||
.lock()
|
||||
.watch_state(&target.share_id, &target.link_id)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let duration = state.duration_secs.filter(|duration| *duration > 0.0)?;
|
||||
Some(at / duration)
|
||||
}
|
||||
|
||||
/// Fetch the blocks a file opens with into the cache, without playing it.
|
||||
///
|
||||
/// For the episode after the one playing, so that autoplay — or a click on
|
||||
@@ -1594,10 +1804,13 @@ impl Engine {
|
||||
if offline {
|
||||
return;
|
||||
}
|
||||
let Some(source) = engine.opened().map(|(_, source)| source) else {
|
||||
let Some((library, source)) = engine.opened() else {
|
||||
return;
|
||||
};
|
||||
match source.open(&share_id, &uid).await {
|
||||
let opened = library
|
||||
.reauthenticating(&share_id, || source.open(&share_id, &uid))
|
||||
.await;
|
||||
match opened {
|
||||
Ok(stream) => stream.warm_edges(),
|
||||
Err(error) => tracing::debug!(%error, "warming the next episode failed"),
|
||||
}
|
||||
@@ -1930,14 +2143,14 @@ impl Engine {
|
||||
}
|
||||
};
|
||||
self.update_download(&key, |item| item.state = DownloadState::Running);
|
||||
let Some((_, source)) = self.ensure_connected().await else {
|
||||
let Some((library, source)) = self.ensure_connected().await else {
|
||||
anyhow::bail!("share is not connected");
|
||||
};
|
||||
let stream = source
|
||||
.open_for_copy(
|
||||
&target.share_id,
|
||||
&node_uid(&target.volume_id, &target.link_id),
|
||||
)
|
||||
let uid = node_uid(&target.volume_id, &target.link_id);
|
||||
let stream = library
|
||||
.reauthenticating(&target.share_id, || {
|
||||
source.open_for_copy(&target.share_id, &uid)
|
||||
})
|
||||
.await?;
|
||||
let revision_id = stream.revision_id().to_owned();
|
||||
let block_sizes = stream.block_sizes().to_vec();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -338,6 +338,18 @@ impl Playback {
|
||||
(end > self.position + 0.5).then_some((label, end))
|
||||
}
|
||||
|
||||
/// Whether the chapters say where the credits start. Most files that are
|
||||
/// not anime have no chapters at all.
|
||||
pub fn knows_credits(&self) -> bool {
|
||||
self.credits_at.is_some()
|
||||
}
|
||||
|
||||
/// Seconds of the file left to play, once its length is known.
|
||||
pub fn remaining(&self) -> Option<f64> {
|
||||
self.duration
|
||||
.map(|duration| (duration - self.position).max(0.0))
|
||||
}
|
||||
|
||||
/// Whether the playhead is inside the run of credits and preview that ends
|
||||
/// the file — what "the episode is over" means when a file states its
|
||||
/// chapters.
|
||||
@@ -504,7 +516,11 @@ fn run(
|
||||
orders: std::sync::mpsc::Receiver<Command>,
|
||||
finished: Arc<AtomicBool>,
|
||||
) {
|
||||
let handle = match player.play(stream) {
|
||||
let opened = match target.resume_at {
|
||||
Some(at) => player.play_from(stream, at),
|
||||
None => player.play(stream),
|
||||
};
|
||||
let handle = match opened {
|
||||
Ok(handle) => handle,
|
||||
Err(error) => {
|
||||
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
||||
@@ -514,10 +530,18 @@ fn run(
|
||||
}
|
||||
};
|
||||
|
||||
// Opened while mpv opens the video, and added once it has: an external
|
||||
// track belongs to the file loaded at the time.
|
||||
let mut sidecars = Some(engine.runtime().spawn({
|
||||
let engine = engine.clone();
|
||||
let target = target.clone();
|
||||
async move { engine.open_sidecars(&target).await }
|
||||
}));
|
||||
let mut subtitles: Vec<StreamHandle> = Vec::new();
|
||||
|
||||
let mut position = target.resume_at.unwrap_or(0.0);
|
||||
let mut 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 +567,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 +578,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 +629,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(
|
||||
@@ -713,6 +744,57 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the dialogue rather than the signs and songs mpv picked by language.
|
||||
/// Not remembered: it is the choice mpv should have made, not the viewer's.
|
||||
fn prefer_full_subtitles(player: &Player) {
|
||||
if let Some(track) = pstr_player::full_subtitles_instead(&player.tracks())
|
||||
&& let Err(error) = player.select_track(TrackKind::Subtitle, Some(track))
|
||||
{
|
||||
tracing::warn!("select dialogue subtitle {track}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand mpv the subtitle files paired with the video it has just loaded.
|
||||
///
|
||||
/// One is turned on only when subtitles are on and the video has none of its
|
||||
/// own playing — a file's own track was chosen by name and language already,
|
||||
/// and an external one is the fallback, not an override.
|
||||
fn add_sidecars(
|
||||
engine: &Engine,
|
||||
target: &PlaybackTarget,
|
||||
player: &Player,
|
||||
opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>,
|
||||
) -> Vec<StreamHandle> {
|
||||
let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref());
|
||||
let playing = player
|
||||
.tracks()
|
||||
.iter()
|
||||
.any(|track| track.kind == TrackKind::Subtitle && track.selected);
|
||||
let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
|
||||
let select = (config.subtitles && !playing)
|
||||
.then(|| {
|
||||
pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref())
|
||||
})
|
||||
.flatten();
|
||||
sidecars
|
||||
.iter()
|
||||
.zip(streams)
|
||||
.enumerate()
|
||||
.filter_map(|(index, (sidecar, stream))| {
|
||||
let title = sidecar.title.as_deref().unwrap_or("External");
|
||||
player
|
||||
.add_subtitle(
|
||||
stream,
|
||||
title,
|
||||
sidecar.language.as_deref(),
|
||||
select == Some(index),
|
||||
)
|
||||
.inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name))
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tell the UI what the file contains and what of it is playing.
|
||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||
engine.emit(Event::Player {
|
||||
|
||||
@@ -248,15 +248,40 @@ 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) = cjk_font_data(bytes, index) else {
|
||||
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
||||
return;
|
||||
};
|
||||
let mut data = egui::FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
ctx.set_fonts(font_definitions(Some(data)));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The fallback font, if egui can read it.
|
||||
///
|
||||
/// egui panics on a font it cannot parse, and this one comes from whatever the
|
||||
/// system has installed. So it is parsed here first, the same way egui does,
|
||||
/// and a font that fails is left out rather than taking the app down.
|
||||
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
|
||||
skrifa::FontRef::from_index(&bytes, index).ok()?;
|
||||
let mut data = egui::FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// The face within a font collection that fontconfig's `%{index}` names.
|
||||
///
|
||||
/// Fontconfig packs a variable font's named instance into the high 16 bits —
|
||||
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
|
||||
/// instance is not a face, and passing it to egui as one stopped the app at
|
||||
/// start.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn fontconfig_face(index: u32) -> u32 {
|
||||
index & 0xFFFF
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
let output = std::process::Command::new("fc-match")
|
||||
@@ -269,6 +294,7 @@ 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))
|
||||
}
|
||||
@@ -855,6 +881,20 @@ const RAMP_STOPS: usize = 64;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
||||
assert_eq!(fontconfig_face(262_144), 0);
|
||||
assert_eq!(fontconfig_face(0x0002_0001), 1);
|
||||
assert_eq!(fontconfig_face(2), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fallback_font_egui_cannot_read_is_left_out() {
|
||||
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
|
||||
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
|
||||
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
|
||||
@@ -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)]
|
||||
@@ -315,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",
|
||||
@@ -385,7 +380,7 @@ pub fn browser(
|
||||
if !browser.trail.is_empty()
|
||||
&& ui
|
||||
.button(egui_phosphor::regular::ARROW_LEFT)
|
||||
.on_hover_text("Up a folder")
|
||||
.hint("Up a folder")
|
||||
.clicked()
|
||||
{
|
||||
browser.up(actions);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Inspectable desktop download queue.
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadState};
|
||||
use crate::{theme, ui};
|
||||
|
||||
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
}
|
||||
}
|
||||
DownloadState::Completed => {
|
||||
// The point of a download is to watch it, and the library is a
|
||||
// detour from here — offline most of all.
|
||||
if ui::accent_button(ui, "Play").clicked() {
|
||||
actions.push(Action::PlayResuming(item.target.clone()));
|
||||
}
|
||||
if ui.button("Open").on_hover_text("Go to its title").clicked() {
|
||||
actions.push(Action::Goto(Page::Title(item.target.title_key.clone())));
|
||||
}
|
||||
if ui
|
||||
.button("Delete")
|
||||
.on_hover_text("Delete the offline copy; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key(), false));
|
||||
actions.push(Action::DeleteOffline(vec![key()], item.target.caption()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||
|
||||
use crate::app::{Action, Page};
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, Art};
|
||||
|
||||
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||
@@ -161,7 +162,7 @@ fn row(
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.on_hover_text(if episode.is_watched() {
|
||||
.hint(if episode.is_watched() {
|
||||
"Remove from history — marks it unwatched"
|
||||
} else {
|
||||
"Remove from history — forgets where you stopped"
|
||||
|
||||
@@ -19,6 +19,11 @@ pub struct Shelves<'a> {
|
||||
pub search: &'a str,
|
||||
/// The grid's shape.
|
||||
pub tiles: Tiles,
|
||||
/// What a crawl in progress has found, if one is.
|
||||
pub crawl: Option<&'a str>,
|
||||
/// Whether to offer posters and synopses: they are off, and the offer has
|
||||
/// not been turned down.
|
||||
pub offer_metadata: bool,
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
|
||||
@@ -28,12 +33,14 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
||||
loaded,
|
||||
search,
|
||||
tiles,
|
||||
crawl,
|
||||
offer_metadata,
|
||||
} = shelves;
|
||||
if !loaded {
|
||||
return placeholder(ui);
|
||||
}
|
||||
if library.is_empty() {
|
||||
return empty_state(ui, actions);
|
||||
return empty_state(ui, crawl, actions);
|
||||
}
|
||||
|
||||
let pick = |indices: &[usize]| -> Vec<&Title> {
|
||||
@@ -48,6 +55,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if !searching && offer_metadata {
|
||||
metadata_offer(ui, actions);
|
||||
ui.add_space(theme::space::XL);
|
||||
}
|
||||
if !searching {
|
||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||
hero(ui, art, title, actions);
|
||||
@@ -304,8 +315,16 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
let mut grouped = view.grouped;
|
||||
if ui
|
||||
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
|
||||
let toggle = ui.toggle_value(&mut grouped, egui_phosphor::regular::STACK);
|
||||
toggle.widget_info(|| {
|
||||
egui::WidgetInfo::selected(
|
||||
egui::WidgetType::SelectableLabel,
|
||||
true,
|
||||
view.grouped,
|
||||
"Group franchises",
|
||||
)
|
||||
});
|
||||
if toggle
|
||||
.on_hover_text(if view.grouped {
|
||||
"One tile per franchise. Click to show every title"
|
||||
} else {
|
||||
@@ -316,17 +335,8 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
||||
actions.push(Action::SetGrouped(grouped));
|
||||
}
|
||||
ui.add_space(theme::space::L);
|
||||
if let Some(filter) = ui::widgets::segmented(
|
||||
ui,
|
||||
view.filter,
|
||||
&[
|
||||
(Filter::All, "All"),
|
||||
(Filter::Series, "Series"),
|
||||
(Filter::Films, "Films"),
|
||||
(Filter::Watching, "Watching"),
|
||||
(Filter::Unwatched, "Not started"),
|
||||
],
|
||||
) {
|
||||
let filters = Filter::ALL.map(|filter| (filter, filter.label()));
|
||||
if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
|
||||
actions.push(Action::SetShelf(filter, view.sort));
|
||||
}
|
||||
}
|
||||
@@ -382,7 +392,7 @@ fn tile_shape(tiles: Tiles) -> (f32, f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The top shelf: one card per part-watched title, scrolling sideways.
|
||||
/// The top shelf: one card per title in progress, scrolling sideways.
|
||||
fn continue_row(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
@@ -395,21 +405,17 @@ fn continue_row(
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for title in titles {
|
||||
let Some(episode) = title.resume() else {
|
||||
let Some(episode) = title.continue_with() else {
|
||||
continue;
|
||||
};
|
||||
let remaining = episode
|
||||
.watch
|
||||
.and_then(|watch| watch.duration_secs)
|
||||
.map(|duration| {
|
||||
format!(
|
||||
"{} left",
|
||||
ui::format_time(
|
||||
duration - episode.watch.map_or(0.0, |w| w.position_secs)
|
||||
)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| episode.label());
|
||||
let remaining = match episode.resume_at() {
|
||||
Some(at) => episode
|
||||
.watch
|
||||
.and_then(|watch| watch.duration_secs)
|
||||
.map(|duration| format!("{} left", ui::format_time(duration - at)))
|
||||
.unwrap_or_else(|| episode.label()),
|
||||
None => format!("Next: {}", episode.label()),
|
||||
};
|
||||
|
||||
let picture = art.of(title);
|
||||
let response = ui::card(
|
||||
@@ -550,7 +556,7 @@ fn tile_menu(
|
||||
});
|
||||
}
|
||||
if continuing
|
||||
&& let Some(episode) = title.resume()
|
||||
&& let Some(episode) = title.continue_with()
|
||||
&& ui
|
||||
.button("Remove from Continue watching")
|
||||
.on_hover_text("Forget where you stopped in it")
|
||||
@@ -639,9 +645,54 @@ fn placeholder(ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
/// Posters and synopses, offered once there is a library to put them on and
|
||||
/// never turned on unasked: switching them on sends title names out.
|
||||
fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||
.inner_margin(egui::Margin::symmetric(14, 12))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Add posters and synopses?").strong());
|
||||
ui.label(ui::muted(
|
||||
"Turning it on in Settings sends the titles in your library — not your \
|
||||
files, and nothing about what you have watched — to AniList or TMDB.",
|
||||
));
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Not now").clicked() {
|
||||
actions.push(Action::DismissMetadataOffer);
|
||||
}
|
||||
if ui::accent_button(ui, "Open settings").clicked() {
|
||||
actions.push(Action::Goto(Page::Settings));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec<Action>) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(120.0);
|
||||
// A share just added: say it is being looked through, rather than
|
||||
// offer to add the one that is already filling the page.
|
||||
if let Some(crawl) = crawl {
|
||||
ui.add(egui::Spinner::new().size(28.0));
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(
|
||||
theme::Role::Title
|
||||
.rich("Looking through the share")
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
ui.label(ui::muted(format!(
|
||||
"Its titles appear here once it has been listed ({crawl})."
|
||||
)));
|
||||
return;
|
||||
}
|
||||
ui.label(
|
||||
theme::Role::Title
|
||||
.rich("Nothing in the library yet")
|
||||
@@ -653,7 +704,7 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
));
|
||||
ui.add_space(theme::space::XL);
|
||||
if ui::accent_button(ui, "Add a share").clicked() {
|
||||
actions.push(Action::Goto(Page::Shares));
|
||||
actions.push(Action::OpenShareForm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,12 +130,29 @@ fn current_row(
|
||||
)));
|
||||
// Only worth offering when there is something to undo: with no
|
||||
// row stored, "clear" would delete nothing.
|
||||
if ui
|
||||
//
|
||||
// A match chosen by hand is asked about first: it is work the
|
||||
// next match run does not redo.
|
||||
let asking = ui.id().with(("forget", &matcher.title_key));
|
||||
if ui.data(|data| data.get_temp::<bool>(asking).unwrap_or(false)) {
|
||||
ui.label(ui::muted("Forget the match you chose?"));
|
||||
if ui::danger_button(ui, "Forget").clicked() {
|
||||
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
}
|
||||
if ui.button("Keep").clicked() {
|
||||
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||
}
|
||||
} else if ui
|
||||
.button("Clear")
|
||||
.on_hover_text("Forget this match and let the next match run decide again")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
if *manual {
|
||||
ui.data_mut(|data| data.insert_temp(asking, true));
|
||||
} else {
|
||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((None, _)) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecor
|
||||
|
||||
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
||||
use crate::theme;
|
||||
use widgets::Named;
|
||||
|
||||
/// Everything the pages need to draw one title's picture.
|
||||
///
|
||||
@@ -172,6 +173,19 @@ pub fn wide_key(title_key: &str) -> String {
|
||||
format!("wide:{title_key}")
|
||||
}
|
||||
|
||||
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
|
||||
/// the Unix time `at` was.
|
||||
pub fn ago(at: i64, now: i64) -> String {
|
||||
let ago = (now - at).max(0);
|
||||
match ago {
|
||||
0..60 => "just now".to_owned(),
|
||||
60..3600 => format!("{} min ago", ago / 60),
|
||||
3600..86_400 => format!("{} h ago", ago / 3600),
|
||||
86_400..172_800 => "1 day ago".to_owned(),
|
||||
_ => format!("{} days ago", ago / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
/// `1:03:47`, or `4:12` for anything under an hour.
|
||||
pub fn format_time(seconds: f64) -> String {
|
||||
if !seconds.is_finite() || seconds < 0.0 {
|
||||
@@ -311,7 +325,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
);
|
||||
if response
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("Clear (Esc)")
|
||||
.hint("Clear (Esc)")
|
||||
.clicked()
|
||||
{
|
||||
search.clear();
|
||||
@@ -374,6 +388,19 @@ pub enum Answer {
|
||||
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
||||
/// the click.
|
||||
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
||||
confirm_with(ctx, id, heading, body, verb, true)
|
||||
}
|
||||
|
||||
/// [`confirm`], for a question whose yes loses nothing — a large download —
|
||||
/// and so is the accent rather than the danger colour.
|
||||
pub fn confirm_with(
|
||||
ctx: &egui::Context,
|
||||
id: &str,
|
||||
heading: &str,
|
||||
body: &str,
|
||||
verb: &str,
|
||||
destroys: bool,
|
||||
) -> Answer {
|
||||
let mut answer = Answer::Pending;
|
||||
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||
.frame(
|
||||
@@ -394,7 +421,12 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
|
||||
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||
ui.add_space(theme::space::XL);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if danger_button(ui, verb).clicked() {
|
||||
let yes = if destroys {
|
||||
danger_button(ui, verb)
|
||||
} else {
|
||||
accent_button(ui, verb)
|
||||
};
|
||||
if yes.clicked() {
|
||||
answer = Answer::Confirmed;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
@@ -498,6 +530,7 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
||||
);
|
||||
|
||||
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||
let response = response.named(text);
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return response;
|
||||
}
|
||||
@@ -595,6 +628,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||
let response = if card.subtitle.is_empty() {
|
||||
response.named(card.name)
|
||||
} else {
|
||||
response.named(format!("{}, {}", card.name, card.subtitle))
|
||||
};
|
||||
// Reached with the arrow keys or Tab: bring it into view, before the
|
||||
// early return below would skip a tile that is just off screen.
|
||||
if response.gained_focus() {
|
||||
|
||||
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
|
||||
use crate::app::{Action, Adjacent};
|
||||
use crate::playback::{Command, Playback};
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, transport::Neighbours};
|
||||
|
||||
/// How long the controls stay up after the pointer stops moving.
|
||||
@@ -139,6 +140,9 @@ pub struct UpNextCard {
|
||||
pub caption: String,
|
||||
/// Its still, once there is one.
|
||||
pub still: Option<egui::TextureHandle>,
|
||||
/// Counting down the end of a file with no credits chapter, where the
|
||||
/// second button cancels the next episode rather than deferring it.
|
||||
pub tail: bool,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
@@ -185,16 +189,28 @@ impl Overlay {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player page shows while a file opens and there is no picture yet.
|
||||
pub struct Waiting<'a> {
|
||||
/// The episode being opened.
|
||||
pub name: &'a str,
|
||||
/// The title it belongs to, as the library names it.
|
||||
pub title: Option<String>,
|
||||
/// The title's widest art, dimmed behind the words.
|
||||
pub art: Option<egui::TextureHandle>,
|
||||
/// Whether one episode is following another, rather than a first click.
|
||||
pub next: bool,
|
||||
}
|
||||
|
||||
/// Draw the picture and its controls.
|
||||
///
|
||||
/// `playback` is `None` in the gap between one episode ending and the next
|
||||
/// having opened — the page stays, because that is where the viewer means to
|
||||
/// be, and `opening` is what it says while they wait.
|
||||
/// `playback` is `None` until the file has opened — on a first click, and in
|
||||
/// the gap between one episode ending and the next — and `opening` is what the
|
||||
/// page says meanwhile.
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
frame: &mut eframe::Frame,
|
||||
playback: Option<&mut Playback>,
|
||||
opening: Option<&str>,
|
||||
opening: Option<Waiting<'_>>,
|
||||
chrome_state: Chrome<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -372,26 +388,60 @@ fn stalled(ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The screen between two episodes: one has ended and the next is opening.
|
||||
/// The screen while a file opens: after a click, or between one episode and
|
||||
/// the next.
|
||||
///
|
||||
/// It keeps a way out, because opening a file over a public link can take
|
||||
/// seconds and a black screen with no controls is indistinguishable from a
|
||||
/// hang.
|
||||
fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut Vec<Action>) {
|
||||
fn between(ui: &mut egui::Ui, waiting: Option<Waiting<'_>>, rect: Rect, actions: &mut Vec<Action>) {
|
||||
let Waiting {
|
||||
name,
|
||||
title,
|
||||
art,
|
||||
next,
|
||||
} = waiting.unwrap_or(Waiting {
|
||||
name: "opening…",
|
||||
title: None,
|
||||
art: None,
|
||||
next: true,
|
||||
});
|
||||
if let Some(art) = &art {
|
||||
// Cover rather than fit: bars around a dimmed backdrop look like a
|
||||
// mistake, and its edges are not what anyone is looking at.
|
||||
let size = art.size_vec2();
|
||||
let scale = (rect.width() / size.x).max(rect.height() / size.y);
|
||||
let shown = size * scale;
|
||||
let uv = Rect::from_center_size(
|
||||
egui::pos2(0.5, 0.5),
|
||||
Vec2::new(rect.width() / shown.x, rect.height() / shown.y),
|
||||
);
|
||||
ui.painter()
|
||||
.image(art.id(), rect, uv, Color32::from_white_alpha(70));
|
||||
}
|
||||
ui.painter().text(
|
||||
rect.center() - Vec2::new(0.0, 16.0),
|
||||
Align2::CENTER_CENTER,
|
||||
"Up next",
|
||||
match (&title, next) {
|
||||
(_, true) => "Up next",
|
||||
(Some(title), false) => title.as_str(),
|
||||
(None, false) => "Opening",
|
||||
},
|
||||
theme::Role::Body.font(),
|
||||
theme::muted(),
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.center() + Vec2::new(0.0, 12.0),
|
||||
Align2::CENTER_CENTER,
|
||||
opening.unwrap_or("opening…"),
|
||||
name,
|
||||
theme::Role::Title.font(),
|
||||
theme::text(),
|
||||
);
|
||||
let spinner = Rect::from_center_size(rect.center() + Vec2::new(0.0, 56.0), Vec2::splat(24.0));
|
||||
ui.put(
|
||||
spinner,
|
||||
egui::Spinner::new().size(24.0).color(Color32::WHITE),
|
||||
);
|
||||
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new()
|
||||
@@ -404,8 +454,6 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
||||
if ui.button("Back").clicked() {
|
||||
actions.push(Action::LeavePlayer);
|
||||
}
|
||||
ui.add_space(theme::space::M);
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -539,7 +587,7 @@ fn up_next_card(
|
||||
theme::Role::Body.font(),
|
||||
Color32::WHITE,
|
||||
);
|
||||
if response.on_hover_text("Play now").clicked() {
|
||||
if response.hint("Play now").clicked() {
|
||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
}
|
||||
|
||||
@@ -566,11 +614,15 @@ fn up_next_card(
|
||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
if ui
|
||||
.button("Watch till the end")
|
||||
.on_hover_text("Play this one out — nothing will be skipped")
|
||||
.clicked()
|
||||
{
|
||||
let (label, hint) = if card.tail {
|
||||
("Cancel", "Stay here — the next episode will not start")
|
||||
} else {
|
||||
(
|
||||
"Watch till the end",
|
||||
"Play this one out — nothing will be skipped",
|
||||
)
|
||||
};
|
||||
if ui.button(label).on_hover_text(hint).clicked() {
|
||||
actions.push(Action::WatchToEnd);
|
||||
}
|
||||
});
|
||||
@@ -695,7 +747,7 @@ fn chrome(
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.on_hover_text(if fullscreen {
|
||||
.hint(if fullscreen {
|
||||
"Leave fullscreen (F)"
|
||||
} else {
|
||||
"Fullscreen (F)"
|
||||
@@ -727,12 +779,31 @@ fn chrome(
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.on_hover_text("Episodes (E)")
|
||||
.hint("Episodes (E)")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleEpisodes);
|
||||
}
|
||||
}
|
||||
// Esc leaves the page and keeps playing; this is the one-step
|
||||
// way to be done with the episode.
|
||||
ui.add_space(theme::space::S);
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
theme::Role::Subhead
|
||||
.rich(egui_phosphor::regular::STOP)
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
)
|
||||
.hint("Stop")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Player(Command::Stop));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -834,7 +905,7 @@ fn episode_drawer(
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.on_hover_text("Close (E)")
|
||||
.hint("Close (E)")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleEpisodes);
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_player::LANGUAGES;
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs, TEXT_SIZE_CHOICES};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// The settings that are a single value rather than a form.
|
||||
///
|
||||
@@ -19,9 +22,11 @@ use crate::ui;
|
||||
/// they are read-only, and passing them one by one is what pushed this
|
||||
/// function past the argument count anybody can read.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Prefs {
|
||||
pub struct Prefs<'a> {
|
||||
pub autoplay: bool,
|
||||
pub auto_skip: bool,
|
||||
/// The languages, read from here.
|
||||
pub playback: &'a PlaybackPrefs,
|
||||
pub appearance: Appearance,
|
||||
pub desktop: DesktopPrefs,
|
||||
/// What the streaming cache holds, once the shares are open.
|
||||
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
prefs: Prefs,
|
||||
prefs: Prefs<'_>,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -51,7 +56,7 @@ pub fn show(
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(width);
|
||||
ui.add_space(theme::space::M);
|
||||
appearance_form(ui, prefs.appearance, actions);
|
||||
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
||||
playback_form(ui, prefs, actions);
|
||||
storage_form(ui, prefs, actions);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
@@ -66,7 +71,12 @@ pub fn show(
|
||||
/// Every control here repaints the whole window the moment it is clicked —
|
||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||
/// only changed a small square would be asking someone to imagine the result.
|
||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||
fn appearance_form(
|
||||
ui: &mut egui::Ui,
|
||||
appearance: Appearance,
|
||||
desktop: DesktopPrefs,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||
@@ -75,6 +85,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for flavor in Flavor::ALL {
|
||||
if palette_card(ui, appearance, flavor)
|
||||
.named(flavor.label())
|
||||
.on_hover_text(flavor.description())
|
||||
.clicked()
|
||||
&& appearance.flavor != flavor
|
||||
@@ -102,6 +113,27 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
||||
}
|
||||
});
|
||||
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Text size",
|
||||
Some("Everything grows with it. Kept on this computer only."),
|
||||
|ui| {
|
||||
let choices: Vec<(u16, String)> = TEXT_SIZE_CHOICES
|
||||
.iter()
|
||||
.map(|&percent| (percent, format!("{percent}%")))
|
||||
.collect();
|
||||
let labels: Vec<(u16, &str)> = choices
|
||||
.iter()
|
||||
.map(|(percent, label)| (*percent, label.as_str()))
|
||||
.collect();
|
||||
if let Some(text_size) = ui::widgets::segmented(ui, desktop.text_size, &labels) {
|
||||
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
|
||||
text_size,
|
||||
..desktop
|
||||
}));
|
||||
}
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Library tiles",
|
||||
@@ -265,22 +297,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
||||
}
|
||||
}
|
||||
response
|
||||
.on_hover_text(accent.label())
|
||||
.hint(accent.label())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// The playback settings that are not per-file.
|
||||
///
|
||||
/// Volume, audio language, subtitle language and speed are all set from the
|
||||
/// player itself, where a viewer can hear the result.
|
||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
/// Volume and speed are set from the player itself, where a viewer can hear
|
||||
/// the result. The languages are here as well as there: what a first episode
|
||||
/// opens with is decided before there is a player to choose from.
|
||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Playback",
|
||||
Some(
|
||||
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
|
||||
"Volume and speed are set from the player. A language chosen in the player for one show is kept for that show.",
|
||||
),
|
||||
|ui| {
|
||||
languages(ui, prefs.playback, actions);
|
||||
let mut autoplay = prefs.autoplay;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
@@ -322,8 +356,80 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The audio and subtitle languages every file opens with, until a show has a
|
||||
/// choice of its own.
|
||||
fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Action>) {
|
||||
let name = |tag: &Option<String>| match tag {
|
||||
Some(tag) => pstr_player::language_name(tag),
|
||||
None => "The file's default".to_owned(),
|
||||
};
|
||||
let audio = playback.audio_language.clone();
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Audio language",
|
||||
Some("Played when a file has it."),
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("audio-language")
|
||||
.selected_text(name(&audio))
|
||||
.show_ui(ui, |ui| {
|
||||
for choice in
|
||||
std::iter::once(None).chain(LANGUAGES.map(|tag| Some(tag.to_owned())))
|
||||
{
|
||||
if ui
|
||||
.selectable_label(audio == choice, name(&choice))
|
||||
.clicked()
|
||||
&& audio != choice
|
||||
{
|
||||
actions.push(Action::SetLanguages {
|
||||
audio: choice,
|
||||
subtitles: playback.subtitles,
|
||||
subtitle: playback.subtitle_language.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
// Off, the file's default, or a language: "off" is a decision of its
|
||||
// own, which no language expresses.
|
||||
let subtitle = playback
|
||||
.subtitles
|
||||
.then(|| playback.subtitle_language.clone());
|
||||
let subtitle_name = |choice: &Option<Option<String>>| match choice {
|
||||
None => "Off".to_owned(),
|
||||
Some(tag) => name(tag),
|
||||
};
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Subtitles",
|
||||
Some("Shown when a file has them in this language."),
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("subtitle-language")
|
||||
.selected_text(subtitle_name(&subtitle))
|
||||
.show_ui(ui, |ui| {
|
||||
let choices = [None, Some(None)]
|
||||
.into_iter()
|
||||
.chain(LANGUAGES.map(|tag| Some(Some(tag.to_owned()))));
|
||||
for choice in choices {
|
||||
if ui
|
||||
.selectable_label(subtitle == choice, subtitle_name(&choice))
|
||||
.clicked()
|
||||
&& subtitle != choice
|
||||
{
|
||||
actions.push(Action::SetLanguages {
|
||||
audio: playback.audio_language.clone(),
|
||||
subtitles: choice.is_some(),
|
||||
subtitle: choice.flatten(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The streaming cache: how big it may get, and a way to empty it.
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
ui,
|
||||
"Storage",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
//! below is the only place a link is ever visible, and only while it is being
|
||||
//! typed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pstr_core::Share;
|
||||
use pstr_core::catalog::CrawlRecord;
|
||||
use pstr_core::library::Library;
|
||||
|
||||
use crate::app::{Action, ShareForm};
|
||||
@@ -16,6 +19,7 @@ use crate::ui;
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
shares: &[Share],
|
||||
crawls: &HashMap<String, CrawlRecord>,
|
||||
library: &Library,
|
||||
form: &mut ShareForm,
|
||||
account: &mut ui::account::AccountPanel,
|
||||
@@ -36,7 +40,7 @@ pub fn show(
|
||||
.iter()
|
||||
.filter(|title| title.share_ids.contains(&share.id))
|
||||
.count();
|
||||
share_row(ui, share, titles, actions);
|
||||
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
|
||||
}
|
||||
|
||||
ui.add_space(theme::space::XXL);
|
||||
@@ -48,7 +52,14 @@ pub fn show(
|
||||
});
|
||||
}
|
||||
|
||||
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
||||
fn share_row(
|
||||
ui: &mut egui::Ui,
|
||||
share: &Share,
|
||||
titles: usize,
|
||||
crawl: Option<&CrawlRecord>,
|
||||
form: &mut ShareForm,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||
@@ -67,7 +78,23 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
||||
if share.has_custom_password {
|
||||
detail.push_str(" · password protected");
|
||||
}
|
||||
match crawl {
|
||||
Some(crawl) if crawl.crawled_at > 0 => {
|
||||
detail.push_str(" · crawled ");
|
||||
detail.push_str(&ui::ago(crawl.crawled_at, crate::engine::unix_now()));
|
||||
}
|
||||
_ => detail.push_str(" · not crawled yet"),
|
||||
}
|
||||
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||
// Why the last look failed, until one succeeds: a link
|
||||
// revoked upstream otherwise just stops growing.
|
||||
if let Some(error) = crawl.and_then(|crawl| crawl.error.as_deref()) {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(format!("Last crawl failed: {error}"))
|
||||
.color(theme::danger()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -81,12 +108,121 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
||||
if ui.button("Crawl").clicked() {
|
||||
actions.push(Action::Crawl(Some(share.id.clone())));
|
||||
}
|
||||
if ui.button("Rename").clicked() {
|
||||
form.renaming = Some(share.id.clone());
|
||||
form.rename_to = share.name.clone();
|
||||
}
|
||||
// Not for a folder of the account, which has no link.
|
||||
if share.folder.is_none()
|
||||
&& ui
|
||||
.button("Re-enter link")
|
||||
.on_hover_text(
|
||||
"For a keyring that lost the link, or a password that changed. \
|
||||
The library, history and downloads stay.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
form.repairing = Some(share.id.clone());
|
||||
form.repair_url.clear();
|
||||
form.repair_password.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
if form.repairing.as_deref() == Some(share.id.as_str()) {
|
||||
repair_form(ui, share, form, actions);
|
||||
}
|
||||
if form.renaming.as_deref() == Some(share.id.as_str()) {
|
||||
rename_form(ui, share, form, actions);
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
|
||||
/// A new name for a share, in its own row. Enter saves, as everywhere else.
|
||||
fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.horizontal(|ui| {
|
||||
let field = ui.add(
|
||||
egui::TextEdit::singleline(&mut form.rename_to)
|
||||
.hint_text("Name")
|
||||
.desired_width(320.0),
|
||||
);
|
||||
let ready = !form.rename_to.trim().is_empty();
|
||||
let entered = field.lost_focus() && ui_enter_pressed(ui.ctx());
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||
.inner
|
||||
.clicked()
|
||||
|| (ready && entered)
|
||||
{
|
||||
actions.push(Action::RenameShare {
|
||||
id: share.id.clone(),
|
||||
name: form.rename_to.trim().to_owned(),
|
||||
});
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
form.renaming = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The same link again, and a password if it has one, for a share already in
|
||||
/// the library.
|
||||
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui.add_space(theme::space::M);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.repair_url)
|
||||
.password(true)
|
||||
.hint_text("The share link, including everything after the #")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.repair_password)
|
||||
.password(true)
|
||||
.hint_text("Link password, if it has one")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::S);
|
||||
let problem = (!form.repair_url.trim().is_empty())
|
||||
.then(|| pstr_core::shares::share_token(form.repair_url.trim()).err())
|
||||
.flatten();
|
||||
if let Some(problem) = &problem {
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich(problem.to_string())
|
||||
.color(theme::danger()),
|
||||
);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let ready = !form.repair_url.trim().is_empty() && problem.is_none();
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RepairShare {
|
||||
id: share.id.clone(),
|
||||
url: form.repair_url.trim().to_owned(),
|
||||
password: Some(form.repair_password.clone())
|
||||
.filter(|password| !password.is_empty()),
|
||||
});
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
form.repairing = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The add form's link field, which the library's empty state focuses.
|
||||
pub fn link_field_id() -> egui::Id {
|
||||
egui::Id::new("share-link")
|
||||
}
|
||||
|
||||
fn ui_enter_pressed(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|input| input.key_pressed(egui::Key::Enter))
|
||||
}
|
||||
|
||||
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Add a share");
|
||||
ui.label(ui::muted(
|
||||
@@ -102,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted("Name"));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.name)
|
||||
.hint_text("Anime")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(theme::space::M);
|
||||
// Enter in any field of the form submits it, as the sign-in
|
||||
// form's do.
|
||||
let mut entered = false;
|
||||
let mut submitted_from = |response: egui::Response| {
|
||||
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
|
||||
};
|
||||
|
||||
ui.label(ui::muted("Link"));
|
||||
ui.horizontal(|ui| {
|
||||
let paste = 60.0;
|
||||
let field = egui::Id::new("share-link");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(ui.available_width() - paste),
|
||||
let field = link_field_id();
|
||||
if std::mem::take(&mut form.focus) {
|
||||
ui.memory_mut(|memory| memory.request_focus(field));
|
||||
}
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.id(field)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(ui.available_width() - paste),
|
||||
),
|
||||
);
|
||||
// The link is masked, so pasting is the only sensible way in —
|
||||
// and a button says that more plainly than the field does.
|
||||
@@ -144,20 +284,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
}
|
||||
ui.add_space(theme::space::M);
|
||||
|
||||
ui.label(ui::muted("Name (optional)"));
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.name)
|
||||
.hint_text("The shared folder's own name")
|
||||
.desired_width(f32::INFINITY),
|
||||
),
|
||||
);
|
||||
ui.add_space(theme::space::M);
|
||||
|
||||
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||
if form.has_password {
|
||||
ui.add_space(theme::space::S);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.password)
|
||||
.password(true)
|
||||
.hint_text("Link password")
|
||||
.desired_width(f32::INFINITY),
|
||||
submitted_from(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.password)
|
||||
.password(true)
|
||||
.hint_text("Link password")
|
||||
.desired_width(f32::INFINITY),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(theme::space::L);
|
||||
let ready = !form.name.trim().is_empty()
|
||||
&& !form.url.trim().is_empty()
|
||||
let ready = !form.url.trim().is_empty()
|
||||
&& problem.is_none()
|
||||
&& (!form.has_password || !form.password.is_empty())
|
||||
&& !form.sending;
|
||||
@@ -167,11 +318,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
ui.add_space(theme::space::S);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
||||
let clicked = ui
|
||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
|
||||
.inner
|
||||
.clicked()
|
||||
{
|
||||
.clicked();
|
||||
if clicked || (ready && entered) {
|
||||
actions.push(Action::AddShare {
|
||||
name: form.name.trim().to_string(),
|
||||
url: form.url.trim().to_string(),
|
||||
@@ -184,8 +335,8 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
||||
if form.sending {
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
ui.label(ui::muted("adding…"));
|
||||
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
||||
ui.label(ui::muted("A name and a link are needed."));
|
||||
} else if form.url.trim().is_empty() {
|
||||
ui.label(ui::muted("Paste a share link to add it."));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||
use crate::playback::PlaybackTarget;
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
use crate::ui::{self, Art, Card};
|
||||
|
||||
pub struct OfflineView<'a> {
|
||||
@@ -96,6 +97,7 @@ pub fn show(
|
||||
)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
season_download(ui, title, season, &offline, actions);
|
||||
season_watched(ui, title, season, actions);
|
||||
});
|
||||
});
|
||||
ui.add_space(theme::space::L);
|
||||
@@ -207,6 +209,29 @@ fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||
}
|
||||
|
||||
/// Download, or delete, a whole season.
|
||||
/// Mark a whole season seen, or unseen once it all is — for someone who
|
||||
/// watched it elsewhere and joins the show here.
|
||||
fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &mut Vec<Action>) {
|
||||
let all = season.episodes.iter().all(|episode| episode.is_watched());
|
||||
let label = if all {
|
||||
"Mark season unwatched"
|
||||
} else {
|
||||
"Mark season watched"
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links: season
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||
.collect(),
|
||||
watched: !all,
|
||||
what: format!("{} {}", title.name, season.label()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn season_download(
|
||||
ui: &mut egui::Ui,
|
||||
title: &Title,
|
||||
@@ -222,9 +247,10 @@ fn season_download(
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in season_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
actions.push(Action::DeleteOffline(
|
||||
season_keys,
|
||||
format!("{} {}", title.name, season.label()),
|
||||
));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download season")
|
||||
@@ -365,9 +391,7 @@ fn header(
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in title_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||
}
|
||||
} else if ui
|
||||
.button("Download show")
|
||||
@@ -760,14 +784,33 @@ fn episode_row(
|
||||
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||
if ui::widgets::watched_mark(ui, watched)
|
||||
.on_hover_text(if watched {
|
||||
"Mark unwatched"
|
||||
} else {
|
||||
"Mark watched"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
|
||||
"Mark unwatched. Right-click for more."
|
||||
} else {
|
||||
"Mark watched. Right-click for more."
|
||||
});
|
||||
// Joining a show partway is the usual reason to mark
|
||||
// anything, and one tick per earlier episode is a chore.
|
||||
mark.context_menu(|ui| {
|
||||
if ui.button("Mark watched up to here").clicked() {
|
||||
let links = title
|
||||
.episodes()
|
||||
.take(
|
||||
title
|
||||
.position_of(&episode.node.share_id, &episode.node.link_id)
|
||||
.map_or(0, |at| at + 1),
|
||||
)
|
||||
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||
.collect();
|
||||
actions.push(Action::MarkEpisodes {
|
||||
key: title.key.clone(),
|
||||
links,
|
||||
watched: true,
|
||||
what: format!("{} up to {}", title.name, episode.label()),
|
||||
});
|
||||
}
|
||||
});
|
||||
if mark.clicked() {
|
||||
actions.push(Action::SetWatched {
|
||||
share_id: episode.node.share_id.clone(),
|
||||
link_id: episode.node.link_id.clone(),
|
||||
@@ -836,6 +879,7 @@ pub(crate) fn still(
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||
let response = response.named(format!("Play {}", episode.label()));
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return;
|
||||
}
|
||||
@@ -941,7 +985,7 @@ fn download(
|
||||
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
actions.push(Action::DeleteOffline(vec![key], episode.label()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::theme;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// How long a message stays up.
|
||||
const SECONDS: f64 = 5.0;
|
||||
@@ -52,6 +53,17 @@ impl Toasts {
|
||||
self.push_inner(now, text.into(), false, Some((label, action)));
|
||||
}
|
||||
|
||||
/// An error with a button on it — "Retry".
|
||||
pub fn push_error_with(
|
||||
&mut self,
|
||||
now: f64,
|
||||
text: impl Into<String>,
|
||||
label: &'static str,
|
||||
action: Action,
|
||||
) {
|
||||
self.push_inner(now, text.into(), true, Some((label, action)));
|
||||
}
|
||||
|
||||
fn push_inner(
|
||||
&mut self,
|
||||
now: f64,
|
||||
@@ -203,7 +215,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
||||
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||
Stroke::new(1.5, ink),
|
||||
);
|
||||
if response.on_hover_text("Dismiss").clicked() {
|
||||
if response.hint("Dismiss").clicked() {
|
||||
pressed = Pressed::Close;
|
||||
}
|
||||
if let Some((label, _)) = &toast.action {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::app::{Action, Adjacent, Page};
|
||||
use crate::playback::{Command, Playback};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
use crate::ui::widgets::Named;
|
||||
|
||||
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
||||
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
|
||||
@@ -163,7 +164,7 @@ pub fn mini(
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
if response.on_hover_text("Show the picture again").clicked() {
|
||||
if response.hint("Show the picture again").clicked() {
|
||||
actions.push(Action::Goto(Page::Player));
|
||||
}
|
||||
}
|
||||
@@ -313,7 +314,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
|
||||
}
|
||||
|
||||
if response
|
||||
.on_hover_text(if playback.paused {
|
||||
.hint(if playback.paused {
|
||||
"Play (Space)"
|
||||
} else {
|
||||
"Pause (Space)"
|
||||
@@ -354,7 +355,7 @@ fn step_button(
|
||||
enabled,
|
||||
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
||||
);
|
||||
if response.on_hover_text(hover).clicked() {
|
||||
if response.hint(hover).clicked() {
|
||||
push(actions);
|
||||
}
|
||||
}
|
||||
@@ -436,7 +437,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
};
|
||||
if ui
|
||||
.button(theme::Role::Subhead.rich(icon))
|
||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
||||
.hint(format!("Mute (M) — {:.0}%", playback.volume))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ToggleMute);
|
||||
@@ -509,7 +510,8 @@ fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>)
|
||||
format!("Speed · {}", ui::format_speed(playback.speed))
|
||||
};
|
||||
ui.menu_button(label, |ui| {
|
||||
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
|
||||
// The same steps `[` and `]` walk.
|
||||
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
|
||||
if ui
|
||||
.selectable_label(
|
||||
(playback.speed - speed).abs() < 1e-6,
|
||||
@@ -566,6 +568,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
||||
let width = ui.available_width();
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::slider(
|
||||
playback.duration.is_some(),
|
||||
playback.position,
|
||||
format!("Position, {}", ui::format_time(playback.position)),
|
||||
)
|
||||
});
|
||||
|
||||
let active = response.hovered() || response.dragged();
|
||||
// Grows rather than jumps when the pointer arrives.
|
||||
|
||||
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// A name for a screen reader, on a control whose face does not say it.
|
||||
///
|
||||
/// A button drawn as an icon glyph is announced as the icon font's private
|
||||
/// code point, and one painted by hand as nothing at all.
|
||||
pub trait Named {
|
||||
/// The control's name to a screen reader.
|
||||
fn named(self, name: impl Into<String>) -> Self;
|
||||
/// Hover text that is also the control's name.
|
||||
fn hint(self, text: impl Into<String>) -> Self;
|
||||
}
|
||||
|
||||
impl Named for egui::Response {
|
||||
fn named(self, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
let enabled = self.enabled();
|
||||
self.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||
self
|
||||
}
|
||||
|
||||
fn hint(self, text: impl Into<String>) -> Self {
|
||||
let text = text.into();
|
||||
self.named(text.clone()).on_hover_text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// An on/off switch with its label beside it, for settings.
|
||||
///
|
||||
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
||||
@@ -98,6 +123,9 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
||||
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
||||
const SIZE: f32 = 20.0;
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, watched, "Watched")
|
||||
});
|
||||
if ui.is_rect_visible(rect) {
|
||||
let ctx = ui.ctx();
|
||||
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||
@@ -278,6 +306,12 @@ pub enum DownloadGlyph {
|
||||
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
||||
const SIZE: f32 = 30.0;
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||
let response = response.named(match glyph {
|
||||
DownloadGlyph::Download => "Download".to_owned(),
|
||||
DownloadGlyph::Progress(done) => format!("Downloading, {:.0}%", done * 100.0),
|
||||
DownloadGlyph::Paused(done) => format!("Download paused, {:.0}%", done * 100.0),
|
||||
DownloadGlyph::Done => "Downloaded".to_owned(),
|
||||
});
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -425,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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::library::{Library, Title, sort_name};
|
||||
use crate::catalog::CatalogNode;
|
||||
use crate::library::{Library, Title, TitleKind, sort_name};
|
||||
use crate::metadata::{MetadataRecord, TitleNames};
|
||||
|
||||
/// What order a grid is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Sort {
|
||||
/// By the name shown, ignoring a leading article.
|
||||
#[default]
|
||||
@@ -50,6 +52,60 @@ impl Sort {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which titles a grid shows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Filter {
|
||||
#[default]
|
||||
All,
|
||||
Series,
|
||||
Films,
|
||||
/// Started and not finished.
|
||||
Watching,
|
||||
/// Not started.
|
||||
Unwatched,
|
||||
/// With at least one episode kept on this device.
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::All,
|
||||
Self::Series,
|
||||
Self::Films,
|
||||
Self::Watching,
|
||||
Self::Unwatched,
|
||||
Self::Downloaded,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "All",
|
||||
Self::Series => "Series",
|
||||
Self::Films => "Films",
|
||||
Self::Watching => "Watching",
|
||||
Self::Unwatched => "Not started",
|
||||
Self::Downloaded => "Downloaded",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the grid shows `title`. `offline` says whether a file is kept
|
||||
/// on this device, which only the front end knows.
|
||||
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
|
||||
let watched = title.watched_count();
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Series => title.kind == TitleKind::Series,
|
||||
Self::Films => title.kind == TitleKind::Film,
|
||||
Self::Watching => {
|
||||
title.resume().is_some() || (watched > 0 && watched < title.episode_count())
|
||||
}
|
||||
Self::Unwatched => watched == 0 && title.resume().is_none(),
|
||||
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name to show for `title`: the provider's, in the form the viewer
|
||||
/// picked, else the share's.
|
||||
pub fn display_name<'a>(
|
||||
@@ -392,4 +448,44 @@ mod tests {
|
||||
assert_eq!(found[0].key, "sousou no frieren");
|
||||
assert_eq!(search(&library, &metadata, "").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filters_split_the_library_by_kind_and_progress() {
|
||||
let film = title("f", "Film", TitleKind::Film);
|
||||
let series = title("s", "Series", TitleKind::Series);
|
||||
let none = |_: &CatalogNode| false;
|
||||
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
|
||||
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
|
||||
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
|
||||
// Nothing watched of either: not started, and not being watched.
|
||||
assert!(Filter::Unwatched.admits(&film, none));
|
||||
assert!(!Filter::Watching.admits(&film, none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_downloaded_filter_admits_a_title_with_any_episode_kept_offline() {
|
||||
let node = |link: &str, episode| CatalogNode {
|
||||
share_id: "s".into(),
|
||||
link_id: link.into(),
|
||||
volume_id: "v".into(),
|
||||
parent_link_id: None,
|
||||
name: format!("Show - S01E0{episode}.mkv"),
|
||||
is_folder: false,
|
||||
media_type: Some("video/x-matroska".into()),
|
||||
size: Some(1),
|
||||
active_revision_id: Some("r".into()),
|
||||
parsed: crate::naming::ParsedName {
|
||||
title: "Show".into(),
|
||||
season: Some(1),
|
||||
episode: Some(episode),
|
||||
year: None,
|
||||
episode_title: None,
|
||||
},
|
||||
};
|
||||
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
|
||||
let show = &library.titles[0];
|
||||
|
||||
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
|
||||
assert!(!Filter::Downloaded.admits(show, |_| false));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use crate::metadata::{
|
||||
use crate::naming::{self, ParsedName};
|
||||
|
||||
/// Bump when adding a migration.
|
||||
pub const SCHEMA_VERSION: i64 = 10;
|
||||
pub const SCHEMA_VERSION: i64 = 12;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
CREATE TABLE nodes (
|
||||
@@ -207,6 +207,68 @@ INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
|
||||
UPDATE title_metadata SET enriched_at = NULL;
|
||||
"#;
|
||||
|
||||
/// What the last crawl of each share came to.
|
||||
///
|
||||
/// Per installation, like the rest of the catalog: when this device last
|
||||
/// looked at a share is nothing another device needs. `crawled_at` is the last
|
||||
/// crawl that finished, `attempted_at` the last that was tried, so a share that
|
||||
/// keeps failing is not retried on every launch. `added` is how many files the
|
||||
/// last finished crawl found that had never been seen before.
|
||||
const MIGRATION_V11: &str = r#"
|
||||
CREATE TABLE share_crawls (
|
||||
share_id TEXT PRIMARY KEY,
|
||||
crawled_at INTEGER NOT NULL,
|
||||
attempted_at INTEGER NOT NULL,
|
||||
files INTEGER NOT NULL,
|
||||
added INTEGER NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Subtitle files beside a video, which the crawl used to drop with the
|
||||
/// `.nfo` files. Rebuilt by every crawl, like `nodes`; a subtitle belongs to
|
||||
/// one video, the one it was paired with by name.
|
||||
const MIGRATION_V12: &str = r#"
|
||||
CREATE TABLE sidecars (
|
||||
share_id TEXT NOT NULL,
|
||||
link_id TEXT NOT NULL,
|
||||
volume_id TEXT NOT NULL,
|
||||
video_link_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
language TEXT,
|
||||
title TEXT,
|
||||
PRIMARY KEY (share_id, link_id)
|
||||
);
|
||||
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
|
||||
"#;
|
||||
|
||||
/// How old a share's last crawl may be before an app looks again on its own.
|
||||
/// Long enough that a launch rarely costs a crawl; short enough that a new
|
||||
/// episode turns up the same day.
|
||||
pub const CRAWL_STALE_AFTER_SECS: i64 = 6 * 60 * 60;
|
||||
|
||||
/// One share's row of `share_crawls`. See [`MIGRATION_V11`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CrawlRecord {
|
||||
/// Unix seconds; `0` for a share no crawl of has finished.
|
||||
pub crawled_at: i64,
|
||||
pub attempted_at: i64,
|
||||
/// Playable files the last finished crawl stored.
|
||||
pub files: u64,
|
||||
/// Of those, how many no earlier crawl had seen.
|
||||
pub added: u64,
|
||||
/// Why the last attempt failed, when it did.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CrawlRecord {
|
||||
/// Whether the share is due another look: nothing has been tried within
|
||||
/// `age_secs` of `now`.
|
||||
pub fn is_stale(&self, now: i64, age_secs: i64) -> bool {
|
||||
now - self.crawled_at.max(self.attempted_at) >= age_secs
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfflineFile {
|
||||
pub revision_id: String,
|
||||
@@ -284,6 +346,21 @@ pub struct CatalogNode {
|
||||
pub parsed: ParsedName,
|
||||
}
|
||||
|
||||
/// A subtitle file paired with the video beside it. See [`build_sidecars`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Sidecar {
|
||||
pub share_id: String,
|
||||
pub link_id: String,
|
||||
pub volume_id: String,
|
||||
/// The video this subtitle is for.
|
||||
pub video_link_id: String,
|
||||
pub name: String,
|
||||
/// The [`crate::language::LANGUAGES`] tag its name states, if any.
|
||||
pub language: Option<String>,
|
||||
/// What else its name says about it — "forced", "SDH" — for the picker.
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Where playback left off.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct WatchState {
|
||||
@@ -408,6 +485,18 @@ impl Catalog {
|
||||
tx.pragma_update(None, "user_version", 10)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 11 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V11)?;
|
||||
tx.pragma_update(None, "user_version", 11)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 12 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V12)?;
|
||||
tx.pragma_update(None, "user_version", 12)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -469,10 +558,23 @@ impl Catalog {
|
||||
}
|
||||
|
||||
// Only files: a folder appearing is not something anyone watches.
|
||||
tx.execute(
|
||||
let now = unix_now();
|
||||
let added = tx.execute(
|
||||
"INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
|
||||
SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0",
|
||||
params![share_id, unix_now()],
|
||||
params![share_id, now],
|
||||
)?;
|
||||
let files = nodes.iter().filter(|node| !node.is_folder).count();
|
||||
tx.execute(
|
||||
"INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error)
|
||||
VALUES (?1, ?2, ?2, ?3, ?4, NULL)
|
||||
ON CONFLICT (share_id) DO UPDATE SET
|
||||
crawled_at = excluded.crawled_at,
|
||||
attempted_at = excluded.attempted_at,
|
||||
files = excluded.files,
|
||||
added = excluded.added,
|
||||
error = NULL",
|
||||
params![share_id, now, files as i64, added as i64],
|
||||
)?;
|
||||
|
||||
if !retain_offline {
|
||||
@@ -483,6 +585,94 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace one share's subtitle files with `sidecars`.
|
||||
///
|
||||
/// Apart from [`Self::replace_share`] rather than inside it: a subtitle
|
||||
/// left over from the crawl before is one that fails to open, quietly,
|
||||
/// which is no reason to make every caller of that pass these along.
|
||||
pub fn replace_sidecars(&mut self, share_id: &str, sidecars: &[Sidecar]) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute(
|
||||
"DELETE FROM sidecars WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
{
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT OR REPLACE INTO sidecars (
|
||||
share_id, link_id, volume_id, video_link_id, name, language, title
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for sidecar in sidecars {
|
||||
insert.execute(params![
|
||||
share_id,
|
||||
sidecar.link_id,
|
||||
sidecar.volume_id,
|
||||
sidecar.video_link_id,
|
||||
sidecar.name,
|
||||
sidecar.language,
|
||||
sidecar.title,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The subtitle files paired with one video, in name order.
|
||||
pub fn sidecars(&self, share_id: &str, video_link_id: &str) -> Result<Vec<Sidecar>> {
|
||||
let mut select = self.conn.prepare_cached(
|
||||
"SELECT share_id, link_id, volume_id, video_link_id, name, language, title
|
||||
FROM sidecars WHERE share_id = ?1 AND video_link_id = ?2 ORDER BY name",
|
||||
)?;
|
||||
let rows = select.query_map(params![share_id, video_link_id], |row| {
|
||||
Ok(Sidecar {
|
||||
share_id: row.get(0)?,
|
||||
link_id: row.get(1)?,
|
||||
volume_id: row.get(2)?,
|
||||
video_link_id: row.get(3)?,
|
||||
name: row.get(4)?,
|
||||
language: row.get(5)?,
|
||||
title: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<rusqlite::Result<_>>()?)
|
||||
}
|
||||
|
||||
/// Record that a crawl of `share_id` failed, keeping what the last
|
||||
/// finished one stored.
|
||||
pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error)
|
||||
VALUES (?1, 0, ?2, 0, 0, ?3)
|
||||
ON CONFLICT (share_id) DO UPDATE SET
|
||||
attempted_at = excluded.attempted_at,
|
||||
error = excluded.error",
|
||||
params![share_id, unix_now(), error],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the last crawl of each share came to, by share id. A share never
|
||||
/// crawled has no entry.
|
||||
pub fn crawls(&self) -> Result<HashMap<String, CrawlRecord>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT share_id, crawled_at, attempted_at, files, added, error FROM share_crawls",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
CrawlRecord {
|
||||
crawled_at: row.get(1)?,
|
||||
attempted_at: row.get(2)?,
|
||||
files: row.get::<_, i64>(3)?.max(0) as u64,
|
||||
added: row.get::<_, i64>(4)?.max(0) as u64,
|
||||
error: row.get(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Every file in a share, folders excluded, ordered for display.
|
||||
pub fn files(&self, share_id: &str) -> Result<Vec<CatalogNode>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
@@ -571,6 +761,10 @@ impl Catalog {
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM sidecars WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
@@ -579,6 +773,10 @@ impl Catalog {
|
||||
"DELETE FROM node_first_seen WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM share_crawls WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1406,6 +1604,182 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Pair each subtitle file in a crawl with the video it is for.
|
||||
///
|
||||
/// By name, the way every player looks for one, in the three layouts shares
|
||||
/// actually have:
|
||||
///
|
||||
/// ```text
|
||||
/// Show/Season 01/S01E01.mkv + S01E01.srt, S01E01.en.srt, S01E01.en.forced.ass
|
||||
/// Show/Season 01/Subs/S01E01.en.srt a subtitles folder beside the videos
|
||||
/// Film (2020)/Subs/Film (2020)/2_English.srt a folder per video inside that
|
||||
/// ```
|
||||
///
|
||||
/// A subtitle whose name starts with no video's is still paired when its
|
||||
/// folder holds one video only — `Film/film.mkv` beside `English.srt`. One
|
||||
/// that matches nothing is left out: offering it under the wrong episode is
|
||||
/// worse than not offering it.
|
||||
pub fn build_sidecars(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Sidecar> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes
|
||||
.iter()
|
||||
.map(|node| (node.uid.link_id.as_str(), node))
|
||||
.collect();
|
||||
let parent_of = |node: &proton_drive_rs::Node| {
|
||||
node.parent_uid
|
||||
.as_ref()
|
||||
.and_then(|uid| by_link.get(uid.link_id.as_str()).copied())
|
||||
};
|
||||
let mut videos: HashMap<&str, Vec<(&proton_drive_rs::Node, &str)>> = HashMap::new();
|
||||
for node in nodes {
|
||||
if !node.is_folder()
|
||||
&& naming::is_video_file(&node.name)
|
||||
&& let Some(parent) = &node.parent_uid
|
||||
{
|
||||
videos
|
||||
.entry(parent.link_id.as_str())
|
||||
.or_default()
|
||||
.push((node, stem(&node.name)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut sidecars: Vec<Sidecar> = nodes
|
||||
.iter()
|
||||
.filter(|node| !node.is_folder() && naming::is_subtitle_file(&node.name))
|
||||
.filter_map(|node| {
|
||||
let own = stem(&node.name);
|
||||
let parent = parent_of(node)?;
|
||||
let (video, rest) = if is_subtitles_folder(&parent.name) {
|
||||
// `Subs/S01E01.en.srt`, for the videos beside `Subs`.
|
||||
beside(
|
||||
videos.get(parent.parent_uid.as_ref()?.link_id.as_str())?,
|
||||
own,
|
||||
)?
|
||||
} else if let Some(holder) = parent_of(parent)
|
||||
&& is_subtitles_folder(&holder.name)
|
||||
{
|
||||
// `Subs/<video>/2_English.srt`: the folder is named for the video.
|
||||
let candidates = videos.get(holder.parent_uid.as_ref()?.link_id.as_str())?;
|
||||
let (video, _) = candidates
|
||||
.iter()
|
||||
.find(|(_, video)| video.eq_ignore_ascii_case(&parent.name))?;
|
||||
(*video, own)
|
||||
} else {
|
||||
beside(videos.get(node.parent_uid.as_ref()?.link_id.as_str())?, own)?
|
||||
};
|
||||
let (language, title) = describe_subtitle(rest);
|
||||
Some(Sidecar {
|
||||
share_id: share_id.to_owned(),
|
||||
link_id: node.uid.link_id.to_string(),
|
||||
volume_id: node.uid.volume_id.to_string(),
|
||||
video_link_id: video.uid.link_id.to_string(),
|
||||
name: node.name.clone(),
|
||||
language,
|
||||
title,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
sidecars.sort_by(|a, b| (&a.video_link_id, &a.name).cmp(&(&b.video_link_id, &b.name)));
|
||||
sidecars
|
||||
}
|
||||
|
||||
/// Which of a video's subtitle files to turn on when the video itself has
|
||||
/// no subtitle playing: the first in the preferred language, or with no
|
||||
/// preference the first of all. One in no stated language is taken over
|
||||
/// nothing, since it is most often the only one there is.
|
||||
///
|
||||
/// Never one in another language: a viewer who asked for English subtitles
|
||||
/// and gets German ones has not been helped.
|
||||
pub fn preferred_sidecar(sidecars: &[Sidecar], language: Option<&str>) -> Option<usize> {
|
||||
let Some(wanted) = language else {
|
||||
return (!sidecars.is_empty()).then_some(0);
|
||||
};
|
||||
sidecars
|
||||
.iter()
|
||||
.position(|sidecar| {
|
||||
sidecar
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|have| crate::language::same_language(wanted, have))
|
||||
})
|
||||
.or_else(|| {
|
||||
sidecars
|
||||
.iter()
|
||||
.position(|sidecar| sidecar.language.is_none())
|
||||
})
|
||||
}
|
||||
|
||||
/// A file name without its extension.
|
||||
fn stem(name: &str) -> &str {
|
||||
name.rsplit_once('.').map_or(name, |(stem, _)| stem)
|
||||
}
|
||||
|
||||
fn is_subtitles_folder(name: &str) -> bool {
|
||||
["subs", "sub", "subtitles", "subtitle"]
|
||||
.iter()
|
||||
.any(|known| known.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Which of the videos in one folder a subtitle named `own` is for, and what
|
||||
/// its name says past the video's.
|
||||
///
|
||||
/// The longest video name the subtitle's starts with, followed by a dot or
|
||||
/// nothing: `S01E1.en` is not `S01E10`'s. Failing that, the only video there.
|
||||
fn beside<'a, 'b>(
|
||||
videos: &[(&'a proton_drive_rs::Node, &str)],
|
||||
own: &'b str,
|
||||
) -> Option<(&'a proton_drive_rs::Node, &'b str)> {
|
||||
let lower = own.to_lowercase();
|
||||
let named = videos
|
||||
.iter()
|
||||
.filter_map(|(video, name)| {
|
||||
let name = name.to_lowercase();
|
||||
if lower == name {
|
||||
Some((*video, name.len(), ""))
|
||||
} else if lower.starts_with(&format!("{name}.")) {
|
||||
// Lowercasing keeps the length of what these names hold;
|
||||
// `get` covers the names where it does not.
|
||||
Some((*video, name.len(), own.get(name.len() + 1..)?))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, length, _)| *length)
|
||||
.map(|(video, _, rest)| (video, rest));
|
||||
named.or(match videos {
|
||||
[(video, _)] => Some((*video, own)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The language and the rest of what a subtitle's name says past its video's:
|
||||
/// `en.forced` is English, "forced"; `2_English_SDH` is English, "SDH".
|
||||
///
|
||||
/// The last word naming a language is the language — in `Film.2020.English`
|
||||
/// the words before it are the film's. Numbers are an index, not a title.
|
||||
fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
|
||||
let words: Vec<&str> = rest
|
||||
.split(['.', '_', ' ', '-', '(', ')', '[', ']'])
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect();
|
||||
let found = words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(index, word)| crate::language::language_tag(word).map(|tag| (index, tag)));
|
||||
let title_words: Vec<&str> = words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, word)| {
|
||||
Some(*index) != found.map(|(at, _)| at) && !word.chars().all(|c| c.is_ascii_digit())
|
||||
})
|
||||
.map(|(_, word)| *word)
|
||||
.collect();
|
||||
let title = (!title_words.is_empty()).then(|| title_words.join(" "));
|
||||
(found.map(|(_, tag)| tag.to_owned()), title)
|
||||
}
|
||||
|
||||
/// What a file's ancestors say about it.
|
||||
#[derive(Debug, Default)]
|
||||
struct Ancestry {
|
||||
@@ -2133,6 +2507,115 @@ mod tests {
|
||||
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crawl_records_when_it_finished_and_what_it_added() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
catalog
|
||||
.replace_share(
|
||||
"s",
|
||||
&[
|
||||
file("s", "a", "Show S01E01.mkv"),
|
||||
file("s", "b", "Show S01E02.mkv"),
|
||||
],
|
||||
)
|
||||
.expect("recrawl");
|
||||
let record = &catalog.crawls().expect("read")["s"];
|
||||
assert!(record.crawled_at > 0);
|
||||
assert_eq!(record.attempted_at, record.crawled_at);
|
||||
assert_eq!((record.files, record.added), (2, 1));
|
||||
assert_eq!(record.error, None);
|
||||
}
|
||||
|
||||
/// A failure says why without forgetting what the last good crawl found,
|
||||
/// and the next good crawl clears it.
|
||||
#[test]
|
||||
fn a_failed_crawl_keeps_the_last_good_one_until_the_next() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
catalog
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE share_crawls SET crawled_at = 5, attempted_at = 5",
|
||||
[],
|
||||
)
|
||||
.expect("age it");
|
||||
catalog
|
||||
.record_crawl_failure("s", "link expired")
|
||||
.expect("fail");
|
||||
let record = catalog.crawls().expect("read")["s"].clone();
|
||||
assert_eq!((record.crawled_at, record.files), (5, 1));
|
||||
assert!(record.attempted_at > 5);
|
||||
assert_eq!(record.error.as_deref(), Some("link expired"));
|
||||
assert!(!record.is_stale(record.attempted_at + 10, 60));
|
||||
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("recrawl");
|
||||
assert_eq!(catalog.crawls().expect("read")["s"].error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_share_forgets_its_crawls() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
catalog.remove_share("s").expect("remove");
|
||||
assert!(catalog.crawls().expect("read").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v11_opens_a_v10_catalog() {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
for migration in [
|
||||
MIGRATION_V1,
|
||||
MIGRATION_V2,
|
||||
MIGRATION_V3,
|
||||
MIGRATION_V4,
|
||||
MIGRATION_V5,
|
||||
MIGRATION_V6,
|
||||
MIGRATION_V7,
|
||||
MIGRATION_V8,
|
||||
MIGRATION_V9,
|
||||
MIGRATION_V10,
|
||||
] {
|
||||
conn.execute_batch(migration).expect("migrate");
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 10)
|
||||
.expect("set version");
|
||||
let catalog = Catalog::from_connection(conn).expect("upgrade");
|
||||
assert!(catalog.crawls().expect("read").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v12_opens_a_v11_catalog() {
|
||||
let conn = Connection::open_in_memory().expect("open");
|
||||
for migration in [
|
||||
MIGRATION_V1,
|
||||
MIGRATION_V2,
|
||||
MIGRATION_V3,
|
||||
MIGRATION_V4,
|
||||
MIGRATION_V5,
|
||||
MIGRATION_V6,
|
||||
MIGRATION_V7,
|
||||
MIGRATION_V8,
|
||||
MIGRATION_V9,
|
||||
MIGRATION_V10,
|
||||
MIGRATION_V11,
|
||||
] {
|
||||
conn.execute_batch(migration).expect("migrate");
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 11)
|
||||
.expect("set version");
|
||||
let catalog = Catalog::from_connection(conn).expect("upgrade");
|
||||
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
|
||||
}
|
||||
|
||||
/// A file keeps the moment it first appeared across recrawls, and one
|
||||
/// that was there before this was recorded reads as 0.
|
||||
#[test]
|
||||
@@ -2894,4 +3377,128 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
// ---- build_sidecars: subtitles paired by name --------------------------
|
||||
|
||||
fn paired(sidecars: &[Sidecar]) -> Vec<(&str, &str, Option<&str>, Option<&str>)> {
|
||||
sidecars
|
||||
.iter()
|
||||
.map(|sidecar| {
|
||||
(
|
||||
sidecar.name.as_str(),
|
||||
sidecar.video_link_id.as_str(),
|
||||
sidecar.language.as_deref(),
|
||||
sidecar.title.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_named_after_a_video_is_paired_with_it_and_says_its_language() {
|
||||
let nodes = vec![
|
||||
root(),
|
||||
folder("s1", Some("root"), "Season 01"),
|
||||
video("ep1", "s1", "S01E1.mkv"),
|
||||
video("ep10", "s1", "S01E10.mkv"),
|
||||
video("plain", "s1", "S01E1.srt"),
|
||||
video("en", "s1", "S01E10.en.srt"),
|
||||
video("forced", "s1", "S01E10.eng.forced.ass"),
|
||||
video("nfo", "s1", "S01E10.nfo"),
|
||||
];
|
||||
assert_eq!(
|
||||
paired(&build_sidecars("share", &nodes)),
|
||||
vec![
|
||||
("S01E1.srt", "ep1", None, None),
|
||||
("S01E10.en.srt", "ep10", Some("eng"), None),
|
||||
("S01E10.eng.forced.ass", "ep10", Some("eng"), Some("forced")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitles_folder_beside_the_videos_holds_theirs() {
|
||||
let nodes = vec![
|
||||
root(),
|
||||
folder("film", Some("root"), "Film (2020)"),
|
||||
video("video", "film", "Film (2020).mkv"),
|
||||
folder("subs", Some("film"), "Subs"),
|
||||
video("de", "subs", "Film (2020).German.srt"),
|
||||
folder("per-video", Some("subs"), "Film (2020)"),
|
||||
video("sdh", "per-video", "3_English_SDH.srt"),
|
||||
];
|
||||
assert_eq!(
|
||||
paired(&build_sidecars("share", &nodes)),
|
||||
vec![
|
||||
("3_English_SDH.srt", "video", Some("eng"), Some("SDH")),
|
||||
("Film (2020).German.srt", "video", Some("ger"), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// One video in the folder can only be the one; two leave a subtitle
|
||||
/// named after neither unpaired rather than guessed.
|
||||
#[test]
|
||||
fn a_subtitle_named_after_no_video_is_paired_only_when_its_folder_has_one() {
|
||||
let nodes = vec![
|
||||
root(),
|
||||
folder("film", Some("root"), "Film"),
|
||||
video("video", "film", "film.1080p.mkv"),
|
||||
video("english", "film", "English.srt"),
|
||||
folder("s1", Some("root"), "Season 01"),
|
||||
video("ep1", "s1", "S01E01.mkv"),
|
||||
video("ep2", "s1", "S01E02.mkv"),
|
||||
video("stray", "s1", "English.srt"),
|
||||
];
|
||||
assert_eq!(
|
||||
paired(&build_sidecars("share", &nodes)),
|
||||
vec![("English.srt", "video", Some("eng"), None)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recrawl_replaces_a_shares_subtitles_and_removing_the_share_drops_them() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let sidecar = |link: &str| Sidecar {
|
||||
share_id: "s".into(),
|
||||
link_id: link.into(),
|
||||
volume_id: "vol".into(),
|
||||
video_link_id: "a".into(),
|
||||
name: format!("{link}.srt"),
|
||||
language: Some("eng".into()),
|
||||
title: None,
|
||||
};
|
||||
catalog
|
||||
.replace_sidecars("s", &[sidecar("old")])
|
||||
.expect("crawl");
|
||||
catalog
|
||||
.replace_sidecars("s", &[sidecar("new")])
|
||||
.expect("recrawl");
|
||||
assert_eq!(
|
||||
catalog.sidecars("s", "a").expect("read"),
|
||||
vec![sidecar("new")]
|
||||
);
|
||||
catalog.remove_share("s").expect("remove");
|
||||
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subtitle_turned_on_is_one_in_the_preferred_language_and_never_another() {
|
||||
let sidecar = |language: Option<&str>| Sidecar {
|
||||
share_id: "s".into(),
|
||||
link_id: "l".into(),
|
||||
volume_id: "v".into(),
|
||||
video_link_id: "a".into(),
|
||||
name: "a.srt".into(),
|
||||
language: language.map(str::to_owned),
|
||||
title: None,
|
||||
};
|
||||
let ger_eng = [sidecar(Some("ger")), sidecar(Some("eng"))];
|
||||
assert_eq!(preferred_sidecar(&ger_eng, Some("en")), Some(1));
|
||||
assert_eq!(preferred_sidecar(&ger_eng, None), Some(0));
|
||||
assert_eq!(preferred_sidecar(&ger_eng, Some("jpn")), None);
|
||||
let ger_unknown = [sidecar(Some("ger")), sidecar(None)];
|
||||
assert_eq!(preferred_sidecar(&ger_unknown, Some("eng")), Some(1));
|
||||
assert_eq!(preferred_sidecar(&[], None), None);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Language tags: what a container or a file name calls a language, and what a
|
||||
//! viewer calls it.
|
||||
|
||||
/// The languages offered by name where a preference is chosen ahead of any
|
||||
/// file, as the three-letter tag a Matroska file carries. The same list
|
||||
/// [`language_name`] knows, and the phones' `LANGUAGES`; mpv treats the two-
|
||||
/// and three-letter forms as one.
|
||||
pub const LANGUAGES: [&str; 29] = [
|
||||
"jpn", "eng", "ger", "fre", "spa", "ita", "por", "dut", "rus", "pol", "swe", "dan", "nor",
|
||||
"fin", "cze", "hun", "tur", "ara", "heb", "hin", "kor", "chi", "tha", "vie", "ukr", "rum",
|
||||
"gre", "ind", "may",
|
||||
];
|
||||
|
||||
/// Each language's name and the tags it goes by, the [`LANGUAGES`] form first.
|
||||
///
|
||||
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
||||
/// the shares this app is pointed at actually contain, and an unrecognised tag
|
||||
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
|
||||
/// on its own.
|
||||
const TABLE: [(&str, &[&str]); 29] = [
|
||||
("English", &["eng", "en"]),
|
||||
("Japanese", &["jpn", "ja"]),
|
||||
("German", &["ger", "de", "deu"]),
|
||||
("French", &["fre", "fr", "fra"]),
|
||||
("Spanish", &["spa", "es"]),
|
||||
("Italian", &["ita", "it"]),
|
||||
("Portuguese", &["por", "pt"]),
|
||||
("Dutch", &["dut", "nl", "nld"]),
|
||||
("Russian", &["rus", "ru"]),
|
||||
("Polish", &["pol", "pl"]),
|
||||
("Swedish", &["swe", "sv"]),
|
||||
("Danish", &["dan", "da"]),
|
||||
("Norwegian", &["nor", "no"]),
|
||||
("Finnish", &["fin", "fi"]),
|
||||
("Czech", &["cze", "cs", "ces"]),
|
||||
("Hungarian", &["hun", "hu"]),
|
||||
("Turkish", &["tur", "tr"]),
|
||||
("Arabic", &["ara", "ar"]),
|
||||
("Hebrew", &["heb", "he"]),
|
||||
("Hindi", &["hin", "hi"]),
|
||||
("Korean", &["kor", "ko"]),
|
||||
("Chinese", &["chi", "zh", "zho"]),
|
||||
("Thai", &["tha", "th"]),
|
||||
("Vietnamese", &["vie", "vi"]),
|
||||
("Ukrainian", &["ukr", "uk"]),
|
||||
("Romanian", &["rum", "ro", "ron"]),
|
||||
("Greek", &["gre", "el", "ell"]),
|
||||
("Indonesian", &["ind", "id"]),
|
||||
("Malay", &["may", "ms", "msa"]),
|
||||
];
|
||||
|
||||
/// A readable name for a language tag, falling back to the tag itself.
|
||||
pub fn language_name(tag: &str) -> String {
|
||||
// Container tags come in both the two- and three-letter forms, and often
|
||||
// with a region suffix: "en", "eng", "en-US", "pt-BR".
|
||||
let base = tag.split(['-', '_']).next().unwrap_or(tag);
|
||||
let Some((name, _)) = TABLE
|
||||
.iter()
|
||||
.find(|(_, tags)| tags.iter().any(|known| known.eq_ignore_ascii_case(base)))
|
||||
else {
|
||||
return tag.to_string();
|
||||
};
|
||||
|
||||
// The region matters when there is one — Brazilian Portuguese is not
|
||||
// Portuguese to anyone choosing between the two.
|
||||
match tag.split_once(['-', '_']) {
|
||||
Some((_, region)) if !region.is_empty() => {
|
||||
format!("{name} ({})", region.to_ascii_uppercase())
|
||||
}
|
||||
_ => (*name).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||
pub fn same_language(a: &str, b: &str) -> bool {
|
||||
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||
}
|
||||
|
||||
/// The [`LANGUAGES`] tag for one word of a file name, if it names a language:
|
||||
/// "en", "eng" and "English" are all `"eng"`.
|
||||
///
|
||||
/// For the words around a subtitle file's name — `Episode 01.en.srt`,
|
||||
/// `2_English.srt` — where a two-letter word is far more often a language than
|
||||
/// anything else.
|
||||
pub fn language_tag(word: &str) -> Option<&'static str> {
|
||||
TABLE
|
||||
.iter()
|
||||
.find(|(name, tags)| {
|
||||
name.eq_ignore_ascii_case(word) || tags.iter().any(|tag| tag.eq_ignore_ascii_case(word))
|
||||
})
|
||||
.map(|(_, tags)| tags[0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
|
||||
assert_eq!(language_name("jpn"), "Japanese");
|
||||
assert_eq!(language_name("ja"), "Japanese");
|
||||
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
|
||||
// An unknown tag is shown as it stands rather than as "Unknown", which
|
||||
// would make two of them indistinguishable.
|
||||
assert_eq!(language_name("mis"), "mis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_named_by_either_tag_or_by_its_name_has_the_tag_a_preference_uses() {
|
||||
assert_eq!(language_tag("en"), Some("eng"));
|
||||
assert_eq!(language_tag("ENG"), Some("eng"));
|
||||
assert_eq!(language_tag("German"), Some("ger"));
|
||||
assert_eq!(language_tag("deu"), Some("ger"));
|
||||
assert_eq!(language_tag("forced"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_language_offered_by_name_is_one_the_table_knows() {
|
||||
let firsts: Vec<&str> = TABLE.iter().map(|(_, tags)| tags[0]).collect();
|
||||
let mut offered = LANGUAGES.to_vec();
|
||||
let mut known = firsts.clone();
|
||||
offered.sort_unstable();
|
||||
known.sort_unstable();
|
||||
assert_eq!(offered, known);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod chapters;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod franchise;
|
||||
pub mod language;
|
||||
pub mod library;
|
||||
pub mod metadata;
|
||||
pub mod naming;
|
||||
@@ -20,6 +21,7 @@ pub mod prefs;
|
||||
pub mod profile;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod subtitles;
|
||||
pub mod sync;
|
||||
|
||||
pub use account::{Account, AccountStore};
|
||||
|
||||
@@ -189,10 +189,44 @@ impl Title {
|
||||
.max_by_key(|episode| episode.last_played())
|
||||
}
|
||||
|
||||
/// What a click on the poster should play: whatever is part-watched, else
|
||||
/// the first unwatched episode, else the first episode.
|
||||
/// What Continue watching offers for this title: the part-watched episode,
|
||||
/// or the one after the episode finished last.
|
||||
///
|
||||
/// Read off whichever episode was touched last. Finishing an episode is
|
||||
/// the most ordinary moment to want the next, and a shelf that dropped the
|
||||
/// show right then was missing its point. An episode reset to the start —
|
||||
/// which is what leaving Continue watching does — says the viewer is done
|
||||
/// with it, so the title is left out. One opened for a few seconds says
|
||||
/// nothing either way and is passed over.
|
||||
pub fn continue_with(&self) -> Option<&Episode> {
|
||||
let latest = self
|
||||
.episodes()
|
||||
.filter(|episode| {
|
||||
episode.watch.is_some_and(|watch| {
|
||||
watch.position_secs == 0.0
|
||||
|| watch.position_secs >= RESUME_FLOOR_SECS
|
||||
|| episode.is_watched()
|
||||
})
|
||||
})
|
||||
.max_by_key(|episode| episode.last_played())?;
|
||||
if latest.resume_at().is_some() {
|
||||
return Some(latest);
|
||||
}
|
||||
if !latest.is_watched() {
|
||||
return None;
|
||||
}
|
||||
let at = self.position_of(&latest.node.share_id, &latest.node.link_id)?;
|
||||
self.episodes()
|
||||
.skip(at + 1)
|
||||
.find(|episode| !episode.is_watched())
|
||||
}
|
||||
|
||||
/// What a click on the poster should play: what Continue watching offers,
|
||||
/// else whatever is part-watched, else the first unwatched episode, else
|
||||
/// the first episode.
|
||||
pub fn next_up(&self) -> Option<&Episode> {
|
||||
self.resume()
|
||||
self.continue_with()
|
||||
.or_else(|| self.resume())
|
||||
.or_else(|| self.episodes().find(|episode| !episode.is_watched()))
|
||||
.or_else(|| self.episodes().next())
|
||||
}
|
||||
@@ -357,13 +391,13 @@ impl Library {
|
||||
self.titles.iter().find(|title| title.key == key)
|
||||
}
|
||||
|
||||
/// Titles with something part-watched, most recent first. The top row of
|
||||
/// the library.
|
||||
/// Titles with something to go on with — see [`Title::continue_with`] —
|
||||
/// most recent first. The top row of the library.
|
||||
pub fn continue_watching(&self) -> Vec<&Title> {
|
||||
let mut titles: Vec<&Title> = self
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|title| title.resume().is_some())
|
||||
.filter(|title| title.continue_with().is_some())
|
||||
.collect();
|
||||
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
|
||||
titles
|
||||
@@ -688,6 +722,64 @@ mod tests {
|
||||
assert_eq!(library.continue_watching().len(), 1);
|
||||
}
|
||||
|
||||
fn three_episodes() -> Vec<CatalogNode> {
|
||||
vec![
|
||||
node("s", "1", "Show - S01E01", Some(1), Some(1)),
|
||||
node("s", "2", "Show - S01E02", Some(1), Some(2)),
|
||||
node("s", "3", "Show - S01E03", Some(1), Some(3)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_episode_puts_the_next_one_in_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
let next = library.titles[0].continue_with().unwrap();
|
||||
assert_eq!(next.node.link_id, "2");
|
||||
assert_eq!(next.resume_at(), None);
|
||||
assert_eq!(library.continue_watching().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_last_episode_leaves_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
for link in ["1", "2", "3"] {
|
||||
states.insert(("s".into(), link.into()), watch(1440.0, 1440.0, true, 10));
|
||||
}
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert!(library.continue_watching().is_empty());
|
||||
}
|
||||
|
||||
/// Leaving Continue watching resets the episode to the start. Offering the
|
||||
/// one after the last finished episode instead would bring the title
|
||||
/// straight back.
|
||||
#[test]
|
||||
fn an_episode_reset_to_the_start_keeps_its_title_out_of_continue_watching() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
|
||||
states.insert(("s".into(), "2".into()), watch(0.0, 1440.0, false, 20));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert!(library.continue_watching().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_few_seconds_of_the_next_episode_do_not_hide_the_one_part_watched() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "1".into()), watch(700.0, 1440.0, false, 10));
|
||||
states.insert(("s".into(), "2".into()), watch(4.0, 1440.0, false, 20));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_episode_after_the_finished_one_is_offered_past_an_older_unwatched_one() {
|
||||
let mut states = HashMap::new();
|
||||
states.insert(("s".into(), "2".into()), watch(1440.0, 1440.0, true, 10));
|
||||
let library = Library::build(three_episodes(), &states);
|
||||
assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||
let nodes = vec![
|
||||
|
||||
@@ -813,6 +813,15 @@ pub fn is_video_file(name: &str) -> bool {
|
||||
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -422,7 +424,7 @@ impl ShareStore {
|
||||
}
|
||||
|
||||
/// [`Self::add`], but only once the link has been opened with the
|
||||
/// password given.
|
||||
/// 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
|
||||
@@ -443,8 +445,35 @@ impl ShareStore {
|
||||
));
|
||||
}
|
||||
let custom_password = custom_password.filter(|password| !password.is_empty());
|
||||
let client = open_link(url, custom_password).await?;
|
||||
let share = self.add(name, url, custom_password)?;
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -540,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
|
||||
@@ -607,6 +655,75 @@ async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClie
|
||||
/// 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.
|
||||
@@ -728,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) {
|
||||
@@ -736,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
|
||||
@@ -783,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
|
||||
@@ -826,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];
|
||||
|
||||
@@ -834,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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -863,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
|
||||
@@ -927,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() {
|
||||
@@ -1052,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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ use libloading::Library;
|
||||
///
|
||||
/// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin
|
||||
/// will have picked — though with `libglvnd` in the way, either order works.
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn candidates() -> Vec<&'static str> {
|
||||
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||
if wayland {
|
||||
@@ -52,6 +52,15 @@ fn candidates() -> Vec<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// eframe's context on macOS is CGL, and the framework exports every GL
|
||||
/// function it implements as a plain symbol — there is no `GetProcAddress` to
|
||||
/// go through, so the direct lookup in [`GlLoader::resolve`] answers for all of
|
||||
/// them.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn candidates() -> Vec<&'static str> {
|
||||
vec!["/System/Library/Frameworks/OpenGL.framework/OpenGL"]
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn candidates() -> Vec<&'static str> {
|
||||
vec!["opengl32.dll"]
|
||||
|
||||
@@ -52,4 +52,4 @@ pub use player::{
|
||||
};
|
||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||
pub use render::VideoRenderer;
|
||||
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
||||
pub use tracks::{LANGUAGES, Track, TrackKind, full_subtitles_instead, language_name, pick_track};
|
||||
@@ -217,6 +217,9 @@ pub struct Player {
|
||||
/// no stream callback can be running.
|
||||
mpv: Mpv,
|
||||
registry: Arc<StreamRegistry>,
|
||||
/// [`Self::play_from`] set mpv's `start`, which holds for every file
|
||||
/// after until it is put back.
|
||||
start_set: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -298,7 +301,11 @@ impl Player {
|
||||
|
||||
protocol::register(&mpv, ®istry)?;
|
||||
|
||||
let player = Self { mpv, registry };
|
||||
let player = Self {
|
||||
mpv,
|
||||
registry,
|
||||
start_set: std::sync::atomic::AtomicBool::new(false),
|
||||
};
|
||||
player.observe_playback_properties()?;
|
||||
Ok(player)
|
||||
}
|
||||
@@ -325,11 +332,64 @@ impl Player {
|
||||
/// The returned handle keeps the URL resolvable; drop it once the file is
|
||||
/// done with. Dropping it does not interrupt playback already underway.
|
||||
pub fn play(&self, stream: VideoStream) -> Result<StreamHandle> {
|
||||
self.clear_start();
|
||||
self.load(stream)
|
||||
}
|
||||
|
||||
fn load(&self, stream: VideoStream) -> Result<StreamHandle> {
|
||||
let handle = self.registry.publish(stream);
|
||||
self.mpv.command("loadfile", &[&handle.url()])?;
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Add a subtitle file to the one playing, read through the same
|
||||
/// registry as the video, so mpv never learns where either came from.
|
||||
///
|
||||
/// Only once the video has loaded: mpv attaches an external track to the
|
||||
/// file open now. `select` turns it on; otherwise it waits in the list.
|
||||
/// The handle keeps it readable, like [`Self::play`]'s.
|
||||
pub fn add_subtitle(
|
||||
&self,
|
||||
stream: VideoStream,
|
||||
title: &str,
|
||||
language: Option<&str>,
|
||||
select: bool,
|
||||
) -> Result<StreamHandle> {
|
||||
let handle = self.registry.publish(stream);
|
||||
let url = handle.url();
|
||||
let flag = if select { "select" } else { "auto" };
|
||||
let mut args = vec![url.as_str(), flag, title];
|
||||
args.extend(language);
|
||||
self.mpv.command("sub-add", &args)?;
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Start playing a stream `seconds` in.
|
||||
///
|
||||
/// Through mpv's `start` option rather than a seek once the file has
|
||||
/// loaded: by then the demuxer has begun reading ahead from the
|
||||
/// beginning, which on a stream is blocks fetched only to be thrown away,
|
||||
/// queued in front of the ones the seek wants.
|
||||
pub fn play_from(&self, stream: VideoStream, seconds: f64) -> Result<StreamHandle> {
|
||||
self.mpv.set_property("start", format!("{seconds}"))?;
|
||||
self.start_set
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
self.load(stream)
|
||||
}
|
||||
|
||||
/// Put `start` back once the file it was for has loaded, so the next
|
||||
/// one starts at its beginning. [`Self::play`] puts it back too, for a
|
||||
/// file that never loaded.
|
||||
fn clear_start(&self) {
|
||||
if self
|
||||
.start_set
|
||||
.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
&& let Err(error) = self.mpv.set_property("start", "none")
|
||||
{
|
||||
tracing::warn!(%error, "could not clear mpv's start");
|
||||
}
|
||||
}
|
||||
|
||||
/// Play anything else mpv can open: a local path, or one of its own
|
||||
/// pseudo-protocols.
|
||||
///
|
||||
@@ -351,7 +411,15 @@ impl Player {
|
||||
/// property that briefly has no value is normal, not fatal.
|
||||
pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> {
|
||||
match self.mpv.wait_event(timeout)? {
|
||||
Ok(event) => Some(translate(event)),
|
||||
Ok(event) => {
|
||||
let event = translate(event);
|
||||
// Not on an end of file: loading one file ends the one it
|
||||
// replaces, before the new one has read `start`.
|
||||
if matches!(event, PlayerEvent::FileLoaded) {
|
||||
self.clear_start();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "mpv event carried an error");
|
||||
Some(PlayerEvent::Other)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! an empty string.
|
||||
|
||||
use libmpv2::Mpv;
|
||||
use pstr_core::subtitles::{SubtitleTrack, full_instead};
|
||||
|
||||
/// What a track carries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -69,6 +70,9 @@ pub struct Track {
|
||||
pub selected: bool,
|
||||
/// Whether the container marks it as the default choice.
|
||||
pub default: bool,
|
||||
/// Whether the container marks it as forced: a subtitle track of the
|
||||
/// on-screen text alone.
|
||||
pub forced: bool,
|
||||
/// True for a track added from outside the file — a sidecar subtitle. None
|
||||
/// of ours are, yet.
|
||||
pub external: bool,
|
||||
@@ -152,9 +156,22 @@ pub fn pick_track(
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||
fn same_language(a: &str, b: &str) -> bool {
|
||||
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||
/// The dialogue track to play in place of the subtitle track mpv chose, when
|
||||
/// mpv chose signs and songs; see [`pstr_core::subtitles::full_instead`].
|
||||
pub fn full_subtitles_instead(tracks: &[Track]) -> Option<i64> {
|
||||
let subtitles: Vec<_> = tracks
|
||||
.iter()
|
||||
.filter(|track| track.kind == TrackKind::Subtitle)
|
||||
.map(|track| SubtitleTrack {
|
||||
id: track.id,
|
||||
language: track.language.clone(),
|
||||
title: track.title.clone(),
|
||||
default: track.default,
|
||||
forced: track.forced,
|
||||
selected: track.selected,
|
||||
})
|
||||
.collect();
|
||||
full_instead(&subtitles)
|
||||
}
|
||||
|
||||
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||
@@ -172,6 +189,7 @@ fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||
codec: text(mpv, index, "codec"),
|
||||
selected: flag(mpv, index, "selected"),
|
||||
default: flag(mpv, index, "default"),
|
||||
forced: flag(mpv, index, "forced"),
|
||||
external: flag(mpv, index, "external"),
|
||||
})
|
||||
}
|
||||
@@ -190,63 +208,7 @@ fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A readable name for a language tag, falling back to the tag itself.
|
||||
///
|
||||
/// Deliberately a short list rather than a full ISO 639 table: it covers what
|
||||
/// the shares this app is pointed at actually contain, and an unrecognised tag
|
||||
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
|
||||
/// on its own.
|
||||
pub fn language_name(tag: &str) -> String {
|
||||
// Container tags come in both the two- and three-letter forms, and often
|
||||
// with a region suffix: "en", "eng", "en-US", "pt-BR".
|
||||
let base = tag
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.unwrap_or(tag)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
let name = match base.as_str() {
|
||||
"en" | "eng" => "English",
|
||||
"ja" | "jpn" => "Japanese",
|
||||
"de" | "ger" | "deu" => "German",
|
||||
"fr" | "fre" | "fra" => "French",
|
||||
"es" | "spa" => "Spanish",
|
||||
"it" | "ita" => "Italian",
|
||||
"pt" | "por" => "Portuguese",
|
||||
"nl" | "dut" | "nld" => "Dutch",
|
||||
"ru" | "rus" => "Russian",
|
||||
"pl" | "pol" => "Polish",
|
||||
"sv" | "swe" => "Swedish",
|
||||
"da" | "dan" => "Danish",
|
||||
"no" | "nor" => "Norwegian",
|
||||
"fi" | "fin" => "Finnish",
|
||||
"cs" | "cze" | "ces" => "Czech",
|
||||
"hu" | "hun" => "Hungarian",
|
||||
"tr" | "tur" => "Turkish",
|
||||
"ar" | "ara" => "Arabic",
|
||||
"he" | "heb" => "Hebrew",
|
||||
"hi" | "hin" => "Hindi",
|
||||
"ko" | "kor" => "Korean",
|
||||
"zh" | "chi" | "zho" => "Chinese",
|
||||
"th" | "tha" => "Thai",
|
||||
"vi" | "vie" => "Vietnamese",
|
||||
"uk" | "ukr" => "Ukrainian",
|
||||
"ro" | "rum" | "ron" => "Romanian",
|
||||
"el" | "gre" | "ell" => "Greek",
|
||||
"id" | "ind" => "Indonesian",
|
||||
"ms" | "may" | "msa" => "Malay",
|
||||
_ => return tag.to_string(),
|
||||
};
|
||||
|
||||
// The region matters when there is one — Brazilian Portuguese is not
|
||||
// Portuguese to anyone choosing between the two.
|
||||
match tag.split_once(['-', '_']) {
|
||||
Some((_, region)) if !region.is_empty() => {
|
||||
format!("{name} ({})", region.to_ascii_uppercase())
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
pub use pstr_core::language::{LANGUAGES, language_name, same_language};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -261,6 +223,7 @@ mod tests {
|
||||
codec: None,
|
||||
selected: false,
|
||||
default: false,
|
||||
forced: false,
|
||||
external: false,
|
||||
}
|
||||
}
|
||||
@@ -282,16 +245,6 @@ mod tests {
|
||||
assert_eq!(sub.label(), "English — Signs & Songs [ass]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
|
||||
assert_eq!(language_name("jpn"), "Japanese");
|
||||
assert_eq!(language_name("ja"), "Japanese");
|
||||
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
|
||||
// An unknown tag is shown as it stands rather than as "Unknown", which
|
||||
// would make two of them indistinguishable.
|
||||
assert_eq!(language_name("mis"), "mis");
|
||||
}
|
||||
|
||||
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
||||
Track {
|
||||
language: language.map(Into::into),
|
||||
|
||||
@@ -223,20 +223,48 @@ impl VideoStream {
|
||||
/// seek, and a seek cancels read-ahead — which here would cancel the very
|
||||
/// fetch it was meant to have started.
|
||||
pub fn warm_edges(&self) {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
let count = self.inner.map.len();
|
||||
let edges: &[usize] = match count {
|
||||
0 => &[],
|
||||
1 => &[0],
|
||||
_ => &[0, count - 1],
|
||||
};
|
||||
for &index in edges {
|
||||
self.warm(edges);
|
||||
}
|
||||
|
||||
/// [`Self::warm_edges`], and the block `fraction` of the way through the
|
||||
/// file: where a resumed episode seeks once its index is read.
|
||||
///
|
||||
/// An estimate, from the bytes being spread evenly over the running time.
|
||||
/// A video's bitrate is not even, so it can land a block off the one the
|
||||
/// seek wants. Then it is one block fetched for nothing, against a round
|
||||
/// trip saved, after the two the edges cost, when it lands.
|
||||
pub fn warm_resume(&self, fraction: f64) {
|
||||
self.warm_edges();
|
||||
if !(0.0..1.0).contains(&fraction) {
|
||||
return;
|
||||
}
|
||||
let offset = (self.inner.map.size() as f64 * fraction) as u64;
|
||||
let count = self.inner.map.len();
|
||||
if let Some(index) = self.inner.map.block_at(offset)
|
||||
&& index != 0
|
||||
&& index + 1 != count
|
||||
{
|
||||
self.warm(&[index]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch these blocks in the background. Detached: see
|
||||
/// [`Self::warm_edges`] for why not read-ahead.
|
||||
fn warm(&self, indices: &[usize]) {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
for &index in indices {
|
||||
let inner = Arc::clone(&self.inner);
|
||||
runtime.spawn(async move {
|
||||
if let Err(error) = block_at(inner, index).await {
|
||||
tracing::debug!(index, %error, "warming an edge block failed");
|
||||
tracing::debug!(index, %error, "warming a block failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1037,6 +1065,21 @@ mod tests {
|
||||
assert_eq!(fake.fetches(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warming_a_resume_fetches_the_block_that_far_in_besides_the_edges() {
|
||||
let fake = Fake::new(&[1000; 10]);
|
||||
let stream = stream_over(Arc::clone(&fake), 0);
|
||||
|
||||
stream.warm_resume(0.45);
|
||||
assert!(
|
||||
eventually(|| {
|
||||
fake.fetches_of(0) == 1 && fake.fetches_of(4) == 1 && fake.fetches_of(9) == 1
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(fake.fetches(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
|
||||
let disk = disk_in("fill").await;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Account and sync
|
||||
|
||||
Signing in to Proton is optional. Without it, the app plays public share links and keeps
|
||||
everything on one device. Signing in adds two things:
|
||||
|
||||
- **Folders of your own Drive** as shares, with no link to copy.
|
||||
- **Sync** of your place in every episode, your shares and your settings, across every device
|
||||
you sign in on.
|
||||
|
||||
## Signing in
|
||||
|
||||
The **Proton account** section is at the top of the **Shares** page.
|
||||
|
||||
1. Enter your Proton address and password, then **Sign in**.
|
||||
2. If your account has two-factor authentication, enter the code from your authenticator app.
|
||||
3. If your account has a separate mailbox password, enter it and choose **Unlock**.
|
||||
|
||||
Proton sometimes asks to confirm you are human. The app then opens a small window with Proton's
|
||||
own CAPTCHA page. Solve it, and sign-in continues.
|
||||
|
||||
The app keeps a session, not your password. The session goes into the system keyring. **Sign
|
||||
out** ends it on Proton's side and deletes it here.
|
||||
|
||||
Not supported yet:
|
||||
|
||||
- **Security keys** (FIDO2). An account whose only second factor is a security key cannot sign
|
||||
in. Add an authenticator app as a second method in your Proton settings.
|
||||
- **Email or SMS verification** instead of a CAPTCHA.
|
||||
|
||||
## Adding a folder of your Drive
|
||||
|
||||
1. Sign in.
|
||||
2. Under **Add from your Drive**, choose **Browse your Drive**.
|
||||
3. Open folders until you reach the one that holds your videos.
|
||||
4. Choose **Add this folder**.
|
||||
|
||||
The folder becomes a share like any link: it is crawled, shown in the library and streamed. It is
|
||||
read through your account, so there is no link or password to store. The same folder has the
|
||||
same identity on every device, so watch history follows it.
|
||||
|
||||
## Sync
|
||||
|
||||
When you are signed in, each device keeps a small file in your Drive. You find them under
|
||||
**Computers** on the Proton Drive website, in a device called `proton-stream`, in the folder
|
||||
`watch-history`.
|
||||
|
||||
**What syncs:**
|
||||
|
||||
- Where you stopped in each episode, and which episodes you watched
|
||||
- Your shares, including their links and passwords (end-to-end encrypted, like every file in
|
||||
your Drive)
|
||||
- Settings: playback, appearance and posters and descriptions, including a TMDB key
|
||||
- Matches you picked by hand, and your audio and subtitle choice for each show
|
||||
|
||||
**What stays on each device:** volume and mute, the streaming cache size, text size, Wi-Fi only,
|
||||
background audio, hardware decoding, window size, and the library's filter and sort order.
|
||||
|
||||
**When it syncs:** when the app starts, every 5 minutes while it is open, when you stop playing,
|
||||
and when it closes. **Sync now** runs it at once. Android also syncs in the background shortly
|
||||
after you leave the app.
|
||||
|
||||
**How conflicts are settled:** each device writes only its own file, and reads everyone else's.
|
||||
For each episode and each setting, the newest change wins. A device that signs in for the first
|
||||
time takes the others' state and does not overwrite it with its own defaults.
|
||||
|
||||
A device that has not synced for 90 days has its file moved to the Drive trash.
|
||||
|
||||
The status line under your address says how the last sync went, for example *Synced 3 min ago ·
|
||||
2 positions from other devices*. To stop syncing, sign out.
|
||||
@@ -7,6 +7,18 @@ screens, while UniFFI exposes the existing catalog, share, streaming, watch-stat
|
||||
and offline logic. Keep reusable behavior in the Rust crates rather than
|
||||
reimplementing it in Kotlin.
|
||||
|
||||
## Android TV
|
||||
|
||||
The same build installs on Android TV: it is listed in the TV launcher
|
||||
(`LEANBACK_LAUNCHER`, with the banner `scripts/render-tv-banner.sh` renders)
|
||||
and requires neither a touch screen nor leanback. Every screen is Compose
|
||||
`clickable`s, which the D-pad already moves between. The player takes a
|
||||
remote's keys while its controls are down: left and right seek ten seconds,
|
||||
the middle button brings the controls up, and play/pause works as on a
|
||||
keyboard. There is no TV-specific layout yet, and none of it has been run on a
|
||||
TV. Signing in to an account, so that shares arrive through sync, is the way
|
||||
to set one up without typing a share link on a remote.
|
||||
|
||||
## Toolchain
|
||||
|
||||
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
||||
|
||||
@@ -20,8 +20,7 @@ and B41 states which of its items are done.
|
||||
|
||||
Individually small, grouped so they are not lost. The items marked **Landed**
|
||||
came with Phases 4 and 5; the rest are still open, which is why this entry is.
|
||||
What remains is one item — the asynchronous track-list read-back — plus the
|
||||
`SettingsStore` construction on the main thread.
|
||||
What remains is one item: the asynchronous track-list read-back.
|
||||
|
||||
- `NativeMpvHost.kt:131-135` reads the track list back immediately after an
|
||||
asynchronous property set, so the selection tick lags by up to a second;
|
||||
@@ -39,8 +38,10 @@ What remains is one item — the asynchronous track-list read-back — plus the
|
||||
unreachable. The constructor is private behind `NativeMpvHost.createOrNull()`,
|
||||
the service holds a nullable host, and the binder hands back a nullable one —
|
||||
which is what `PlayerScreen` was already written for.
|
||||
- `PlaybackService.kt:43` constructs `SettingsStore` on the main thread inside a
|
||||
hot callback; the first SharedPreferences access is disk I/O.
|
||||
- **Landed.** `PlaybackService.kt:43` constructs `SettingsStore` on the main
|
||||
thread inside a hot callback; the first SharedPreferences access is disk I/O.
|
||||
`ProtonStreamApplication` now loads the file on a background thread at
|
||||
startup, so every later read is from memory.
|
||||
- **Landed.** `MainActivity` set `bound` only in `onServiceConnected`, so an
|
||||
activity destroyed with a bind still pending never unbound. It is now set from
|
||||
the `bindService` return value.
|
||||
@@ -117,15 +118,13 @@ position advances, which can only happen if real decrypted bytes reached the
|
||||
decoder. One live run serves all five cases, because each re-crawl of a real
|
||||
share costs about six minutes and the crawl is not what any of them tests.
|
||||
|
||||
**Still open, and the reason this entry stays open.** `picture-in-picture` is
|
||||
`pending`. It is no longer blocked on the app —
|
||||
[B40](#b40--picture-in-picture-was-entered-but-never-adapted-to) landed, so
|
||||
there is correct behaviour to assert — but the live suite drives the engine and
|
||||
libmpv directly, and PiP is a property of the window, so the case needs an
|
||||
activity-level driver that does not exist yet. Every run still prints that the
|
||||
matrix is incomplete. `process-recreation` has been promoted off its `xfail`
|
||||
now that [B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
|
||||
has landed, and has not been re-run on hardware since.
|
||||
**Still open, and the reason this entry stays open.** The matrix has not been
|
||||
run on hardware since the 2026-10 redesign of every screen, and
|
||||
`process-recreation` has not been re-run since
|
||||
[B35](#b35--navigation-and-player-state-did-not-survive-process-recreation)
|
||||
landed. The `picture-in-picture` case that was `pending` below went with
|
||||
Picture-in-Picture itself, which was later removed (`docs/ANDROID.md`), so
|
||||
nothing is `pending` any more.
|
||||
|
||||
**Verified on hardware**, 2026-08-08, Pixel 6 (`arm64-v8a`, API 37) against the
|
||||
signed minified release APK:
|
||||
@@ -146,6 +145,27 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
||||
|
||||
## Fixed
|
||||
|
||||
### B65 — the desktop app quit at start on a variable CJK font
|
||||
|
||||
**Symptom.** On Secureblue (Fedora Atomic) the window showed for a moment and
|
||||
the process panicked: `Error parsing "system-cjk-fallback" TTF/OTF font file:
|
||||
Invalid index 262144 for font collection`
|
||||
([#1](https://github.com/narrrl/proton-stream/issues/1)).
|
||||
|
||||
**Cause.** The CJK fallback is whatever `fc-match "Noto Sans CJK JP"` names,
|
||||
with the face taken from `%{index}`. For a variable font fontconfig packs the
|
||||
named instance into that number's high 16 bits; Fedora ships Noto Sans CJK as a
|
||||
variable font, so it answered 262144 — instance 4 of face 0. egui took it as a
|
||||
face index, found no such face, and panics on a font it cannot parse.
|
||||
|
||||
**Fix.** `theme.rs` keeps only the low 16 bits, the face. And the fallback is
|
||||
parsed with skrifa, as egui parses it, before it is handed over; one that fails
|
||||
is logged and left out, so no installed font can stop the app again.
|
||||
|
||||
**Verified.** Workspace gate, with
|
||||
`a_variable_fonts_named_instance_is_not_taken_for_a_face` and
|
||||
`a_fallback_font_egui_cannot_read_is_left_out`. Not yet run on Secureblue.
|
||||
|
||||
### B64 — Android offline downloads looked stuck
|
||||
|
||||
**Symptom.** A queued episode, and more so a queued season, sat at a progress
|
||||
@@ -1289,6 +1309,11 @@ out rather than leaving them mid-timeout.
|
||||
suppressed. The `picture-in-picture` acceptance case is now implementable — it
|
||||
was `pending` precisely because the assertion would have had to assert this bug.
|
||||
|
||||
**Since removed.** Picture-in-Picture itself was taken out later: a PiP window
|
||||
that never sized its video right proved less use than background audio and the
|
||||
lock-screen transport (`docs/ANDROID.md`). This entry records a fix to code
|
||||
that no longer exists.
|
||||
|
||||
### B45 — the play/pause button did nothing, everywhere
|
||||
|
||||
**Symptom.** Once an episode was open, every transport that can pause it was
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Command-line reference
|
||||
|
||||
The desktop packages install two programs:
|
||||
|
||||
- `proton-stream`, the app
|
||||
- `pstr`, a command-line tool that does the app's work without a window
|
||||
|
||||
Both use the same shares, library, settings and keyring, so a share added with `pstr` shows up in
|
||||
the app.
|
||||
|
||||
## proton-stream
|
||||
|
||||
```bash
|
||||
proton-stream # open the app
|
||||
proton-stream 'https://drive.proton.me/urls/TOKEN#KEY' # open it with the Add form filled in
|
||||
```
|
||||
|
||||
Only one window runs at a time. A second `proton-stream` brings the open window forward, hands it
|
||||
any share link it was given, and exits.
|
||||
|
||||
A link given as an argument is visible to other users of the computer in the process list while
|
||||
the command runs, and it may land in your shell history. Pasting it into the window with
|
||||
**Ctrl+V** does neither.
|
||||
|
||||
## pstr
|
||||
|
||||
`pstr --help` lists every command, and `pstr <command> --help` explains one. `pstr` never takes a
|
||||
password or key as an argument; it asks for them.
|
||||
|
||||
### Shares
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `pstr add --name NAME URL` | Add a public share link. Quote the URL, because of the `#`. |
|
||||
| `pstr add --name NAME URL --custom-password` | The same, and ask for the link's own password |
|
||||
| `pstr add-folder --name NAME VOLUME:LINK` | Add a folder of your Drive, by the id `pstr browse` prints |
|
||||
| `pstr shares` | List the shares and their ids |
|
||||
| `pstr remove ID` | Remove a share and delete its link and password from the keyring |
|
||||
|
||||
### Account
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `pstr login ADDRESS` | Sign in. Asks for the password, a two-factor code and a mailbox password as the account needs them. |
|
||||
| `pstr logout` | Sign out and delete the stored session |
|
||||
| `pstr account` | Show who is signed in |
|
||||
| `pstr browse [VOLUME:LINK]` | List your Drive's top-level places, or one folder |
|
||||
| `pstr sync` | Sync watch history now |
|
||||
|
||||
`pstr login` cannot show a CAPTCHA. If Proton asks for one, sign in once in the app instead.
|
||||
|
||||
### Library
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `pstr crawl [--share ID]` | Read every share, or one, into the library |
|
||||
| `pstr list [--share ID]` | Print what the library holds |
|
||||
|
||||
### Posters and descriptions
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `pstr metadata status` | Show whether lookups are on, and which provider |
|
||||
| `pstr metadata set --on --provider anilist` | Turn lookups on with AniList |
|
||||
| `pstr metadata set --on --provider tmdb --api-key` | Turn lookups on with TMDB, and ask for the key |
|
||||
| `pstr metadata set --off` | Turn lookups off, and delete every stored answer |
|
||||
| `pstr metadata match [--force] [--limit N]` | Look up titles now. `--force` asks again about titles that already have an answer. |
|
||||
|
||||
### Testing playback
|
||||
|
||||
These two are for finding out which layer is slow or broken.
|
||||
|
||||
```bash
|
||||
pstr bench --file Frieren --verify # measure seek time, throughput and memory, no player
|
||||
pstr play --file Frieren --disk-cache # play in a plain mpv window
|
||||
```
|
||||
|
||||
| Option | Does |
|
||||
|---|---|
|
||||
| `--file TEXT` | The first file whose name or title contains `TEXT` |
|
||||
| `--share ID` | Look in one share only |
|
||||
| `--disk-cache` | Keep decrypted blocks on disk, as the app does |
|
||||
| `--readahead N` | Blocks to fetch ahead of the reader |
|
||||
| `--ring-mib N` | Memory for blocks, in MiB (128 by default) |
|
||||
| `--verify` | `bench` only: compare a read against the SDK's own download |
|
||||
| `--throughput-mib N` | `bench` only: how much to read for the speed test (32 by default) |
|
||||
| `--start SECONDS` | `play` only: start at this position |
|
||||
| `--headless` | `play` only: decode with no window and no sound |
|
||||
| `--for-seconds S` | `play` only: stop after this many seconds |
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Does |
|
||||
|---|---|
|
||||
| `RUST_LOG` | How much both programs log to the terminal, for example `RUST_LOG=debug` |
|
||||
| `PSTR_FRAME_CAP` | The app's frame rate limit on Wayland. 60 by default; `0` turns it off. |
|
||||
@@ -15,10 +15,7 @@
|
||||
|
||||
## Build order from here
|
||||
|
||||
1. **Sidecar subtitle files** — see the "not done" note at the end of this
|
||||
section. The work is in `pstr-core`: crawl them, and pair them to a video by
|
||||
name.
|
||||
2. Packaging is in place (`packaging/README.md`); what is left there is running
|
||||
1. Packaging is in place (`packaging/README.md`); what is left there is running
|
||||
the macOS half on a real host, and a release workflow that drives every
|
||||
platform.
|
||||
|
||||
@@ -151,12 +148,20 @@ Note the glyph trap: egui's bundled fonts cover the transport icons
|
||||
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
||||
on a button wants checking on screen before it ships.
|
||||
|
||||
Not done: **sidecar subtitle files**. A `.srt`/`.ass` sitting beside the video in
|
||||
the share is invisible to all of this — the crawl stores video files only, so
|
||||
there is nothing to offer in the menu. The player side is nearly free once the
|
||||
catalog has them (`sub-add pstr://<token>` through the same registry, since mpv
|
||||
reads an external subtitle through the same protocol); the work is in
|
||||
`pstr-core`: crawl them, and pair them to a video by name.
|
||||
**Sidecar subtitle files.** A `.srt`, `.ass`, `.ssa` or `.vtt` beside a video
|
||||
is paired with it by name at crawl time (`catalog::build_sidecars`): the video's
|
||||
name followed by a dot or nothing (`S01E01.en.forced.ass`), the same inside a
|
||||
`Subs` folder beside the videos, the RARBG layout `Subs/<video>/2_English.srt`,
|
||||
and failing those the only video in the folder. The words past the video's
|
||||
name give the language and the picker's title. They are stored in `sidecars`
|
||||
(schema v12), rebuilt by every crawl like `nodes`.
|
||||
|
||||
Each client opens them while mpv opens the video and adds them with `sub-add
|
||||
pstr://<token>` through the same registry once mpv reports the file loaded. One
|
||||
is turned on only when subtitles are on and the file has none of its own
|
||||
playing, and only in the preferred language (`catalog::preferred_sidecar`); the
|
||||
rest wait in the subtitle menu. A downloaded episode played without a session
|
||||
goes without them, and they are not downloaded with it.
|
||||
|
||||
## Design decisions already taken
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# FAQ
|
||||
|
||||
## Is this an official Proton app?
|
||||
|
||||
No. It is an unofficial, open-source client written by one developer. Proton AG has not reviewed,
|
||||
endorsed or supported it. "Proton" and "Proton Drive" are Proton AG's trademarks.
|
||||
|
||||
## Do I need a Proton account?
|
||||
|
||||
No, not to watch a public share link. You need one to add folders of your own Drive and to sync
|
||||
your place across devices. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||
|
||||
## Does it download the whole file before playing?
|
||||
|
||||
No. Proton Drive stores a file as blocks that each decrypt on their own, so the app fetches only
|
||||
the blocks around the point you are watching. A seek fetches the block it lands on. On a
|
||||
26-minute 1080p episode the first frame takes about 2.3 seconds.
|
||||
|
||||
## Does it transcode?
|
||||
|
||||
No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work,
|
||||
with hardware decoding where the device has it.
|
||||
|
||||
## Does it provide any videos?
|
||||
|
||||
No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue,
|
||||
no search of other people's shares and no content of its own.
|
||||
|
||||
## Why are there no posters?
|
||||
|
||||
Posters and descriptions are off by default, because looking them up sends your title names to
|
||||
AniList or TMDB. Turn them on in **Settings → Posters and descriptions**. See
|
||||
[Privacy](PRIVACY.md).
|
||||
|
||||
## A title is matched to the wrong show. How do I fix it?
|
||||
|
||||
Open the title and choose **Match…** (or **Change match**). Search for the right entry and pick
|
||||
it. A match you pick is never replaced, and it syncs to your other devices.
|
||||
|
||||
## The episodes of one show appear as separate titles.
|
||||
|
||||
The app takes the show's name from its folder. Put every episode of a show in one folder, or in
|
||||
season folders below it. [How the library reads your
|
||||
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has examples.
|
||||
|
||||
## Can several people share one library?
|
||||
|
||||
Yes. Send them the same share link. Each person's app keeps its own watch history; sync only
|
||||
connects devices that sign in to the same Proton account.
|
||||
|
||||
## Is there a macOS version?
|
||||
|
||||
Not as a release. The app builds and runs on macOS from source, and `scripts/build.sh` can make a
|
||||
`.app` and a `.dmg` on a Mac, but no release ships one yet.
|
||||
|
||||
## Can I cast to a TV?
|
||||
|
||||
Not yet. On an Android TV, install the APK and run the app on the TV itself.
|
||||
|
||||
## Where do I report a bug?
|
||||
|
||||
Open an issue on [GitHub](https://github.com/narrrl/proton-stream/issues). Say which platform
|
||||
and version you use, and what you did.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Getting started
|
||||
|
||||
This page takes you from nothing to a playing episode. It takes a few minutes.
|
||||
|
||||
## 1. Install the app
|
||||
|
||||
Download the package for your system from the
|
||||
[latest release](https://github.com/narrrl/proton-stream/releases/latest) and install it.
|
||||
[Installation](INSTALL.md) has the commands for each platform.
|
||||
|
||||
## 2. Add a share
|
||||
|
||||
A *share* is a folder the app reads videos from. There are two kinds:
|
||||
|
||||
- **A public share link.** Someone sends you a Proton Drive link such as
|
||||
`https://drive.proton.me/urls/ABC123#xyz`. You need no Proton account for this.
|
||||
- **A folder of your own Drive.** Sign in to your Proton account, then pick a folder. See
|
||||
[Account and sync](ACCOUNT_AND_SYNC.md).
|
||||
|
||||
To add a public link on the desktop:
|
||||
|
||||
1. Copy the whole link, including the part after `#`. That part is the key that decrypts the
|
||||
share, so the link does not work without it.
|
||||
2. Open the **Shares** page, or press **Ctrl+V** anywhere outside a text field.
|
||||
3. Paste the link into **Link**.
|
||||
4. If the person who shared it set a password of their own, turn on **The link asks for a
|
||||
password** and type it.
|
||||
5. Click **Add share**.
|
||||
|
||||
The app opens the link to check it before it saves anything. If the link is wrong, it tells you
|
||||
why and stores nothing.
|
||||
|
||||
On Android and iOS, tap **Add share** on the **Shares** tab. On Android you can also share the
|
||||
link to the app from your browser. [Android and iOS](MOBILE.md) has the details.
|
||||
|
||||
## 3. Wait for the library to fill
|
||||
|
||||
The app reads the share's folders. This is called a *crawl*. Titles appear in the **Library** as
|
||||
they are found. A large share takes a minute or two.
|
||||
|
||||
The folder layout decides how titles are grouped. Each show should have its own folder:
|
||||
|
||||
```text
|
||||
Sousou no Frieren/
|
||||
Season 01/
|
||||
S01E01.mkv
|
||||
S01E02.mkv
|
||||
Paprika (2006).mkv
|
||||
```
|
||||
|
||||
A file without episode numbers is shown as a film. [How the library reads your
|
||||
folders](USER_GUIDE.md#how-the-library-reads-your-folders) has the full rules.
|
||||
|
||||
## 4. Press play
|
||||
|
||||
Open a title and click **Play**. The episode starts in a few seconds, and you can drag the seek
|
||||
bar to any point. The app remembers where you stopped, so **Resume** takes you back.
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Posters and descriptions.** Turn them on in **Settings → Posters and descriptions**. They are
|
||||
off by default, because the app then sends your title names to AniList or TMDB. See
|
||||
[Privacy](PRIVACY.md).
|
||||
- **Your place on every device.** Sign in to Proton on each device, and your position in every
|
||||
episode follows you. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||
- **Keyboard.** Press **?** on the desktop for the list of shortcuts.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Installation
|
||||
|
||||
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has packages for Linux
|
||||
and Windows, an APK for Android and an IPA for iOS, plus a `SHA256SUMS` file to check them
|
||||
against. The version is part of each file name.
|
||||
|
||||
| Platform | File | Needs |
|
||||
|---|---|---|
|
||||
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` | x86_64 |
|
||||
| Fedora | `proton-stream-<version>-1.x86_64.rpm` | x86_64 |
|
||||
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` | x86_64, libmpv 2 |
|
||||
| Arch Linux | Built from the repository with `makepkg` | |
|
||||
| Windows | `proton-stream-<version>-x64.msi` or `…-x86_64-windows.zip` | x64 |
|
||||
| Android, Android TV | `proton-stream-<version>-android.apk` | Android 12 or newer, arm64 or x86_64 |
|
||||
| iOS, iPadOS | `proton-stream-<version>-ios.ipa` | iOS 17 or newer, SideStore or AltStore |
|
||||
|
||||
## Linux
|
||||
|
||||
The desktop app needs libmpv, WebKitGTK 4.1 (for the CAPTCHA window when you sign in) and a
|
||||
Secret Service keyring such as GNOME Keyring or KWallet. The packages pull in everything except
|
||||
the keyring, which most desktops already have.
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
Download the `.deb`, then:
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-stream_*_amd64.deb
|
||||
```
|
||||
|
||||
### Fedora
|
||||
|
||||
Download the `.rpm`, then:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./proton-stream-*.x86_64.rpm
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
|
||||
The `PKGBUILD` in `packaging/` builds the checkout it sits in. It needs network access, so cargo
|
||||
can fetch crates:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/narrrl/proton-stream.git
|
||||
cd proton-stream/packaging
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
### Other distributions
|
||||
|
||||
Unpack the tarball anywhere. It holds `proton-stream` (the app), `pstr` (the
|
||||
[command line](CLI.md)) and the icon. Install libmpv 2 and WebKitGTK 4.1 from your
|
||||
distribution first.
|
||||
|
||||
Both packages install:
|
||||
|
||||
- `/usr/bin/proton-stream`, the app, listed as **Proton Stream** in your applications menu
|
||||
- `/usr/bin/pstr`, the command line
|
||||
|
||||
## Windows
|
||||
|
||||
Download either file from the release:
|
||||
|
||||
- **The `.msi` installer** installs to `Program Files\Proton Stream`, adds a Start menu entry
|
||||
and puts `pstr` on the PATH.
|
||||
- **The portable `.zip`** runs from any folder. Keep every file in it together: the app does not
|
||||
start without `libmpv-2.dll` and `WebView2Loader.dll` beside it.
|
||||
|
||||
The binaries are not code-signed yet, so SmartScreen may warn the first time. Choose **More
|
||||
info**, then **Run anyway**. The sign-in window uses the WebView2 runtime that comes with Windows
|
||||
10 and 11.
|
||||
|
||||
## Android and Android TV
|
||||
|
||||
1. Download `proton-stream-<version>-android.apk` on the device.
|
||||
2. Open it. Android asks once to allow installs from your browser or file manager.
|
||||
3. Install.
|
||||
|
||||
The APK is signed, so later versions install over it and keep your library. On Android TV the app
|
||||
appears in the launcher; installing needs a file manager or `adb install`.
|
||||
|
||||
## iOS and iPadOS
|
||||
|
||||
The `.ipa` is unsigned. [SideStore](https://sidestore.io) or [AltStore](https://altstore.io)
|
||||
signs it with your own Apple ID when it installs:
|
||||
|
||||
1. Install SideStore or AltStore by following its own guide.
|
||||
2. Download `proton-stream-<version>-ios.ipa`.
|
||||
3. Open it with SideStore or AltStore.
|
||||
|
||||
A free Apple ID has to refresh the app every 7 days. SideStore and AltStore do that for you.
|
||||
|
||||
## Updating
|
||||
|
||||
Install the new release over the old one. Your shares, library, watch history and settings stay.
|
||||
The app does not check for updates by itself; watch the
|
||||
[releases](https://github.com/narrrl/proton-stream/releases) on GitHub.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Remove the package the way you installed it, for example `sudo apt remove proton-stream`.
|
||||
This leaves your data behind. To remove that too:
|
||||
|
||||
1. In the app, remove each share on the **Shares** page. This deletes its link and password
|
||||
from the keyring and its downloads from the disk.
|
||||
2. Sign out on the **Shares** page, if you signed in.
|
||||
3. Delete the app's folders, listed in [Privacy](PRIVACY.md#what-stays-on-your-device).
|
||||
|
||||
On Android and iOS, uninstalling the app deletes everything it stored.
|
||||
|
||||
## Building from source
|
||||
|
||||
Rust 1.96 or newer, plus the libmpv (≥ 2.0) development headers, and WebKitGTK 4.1's on Linux:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/narrrl/proton-stream.git
|
||||
cd proton-stream
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
[Development](DEVELOPMENT.md) and `packaging/README.md` have the details, including Windows, the
|
||||
Android toolchain and iOS builds on macOS.
|
||||
@@ -16,6 +16,7 @@ file of the same name is the one to change with it.
|
||||
| `ui/*Screen.kt`, `ui/AccountUi.kt`, `ui/LanguagePicker.kt` | `UI/*View.swift`, `UI/AccountUI.swift`, `UI/LanguagePicker.swift` |
|
||||
| `ui/RemoteArtwork.kt`, `ui/theme/*` | `UI/RemoteArtwork.swift`, `UI/Theme/*` |
|
||||
| `download/*` | `Download/*` |
|
||||
| `sync/NewEpisodesWorker.kt` | `App/NewEpisodes.swift` |
|
||||
| `playback/PlayerScreen.kt`, `NativeMpvHost.kt`, `PlaybackService.kt`, `StreamSession.kt` | `Player/PlayerView.swift`, `Player/PlayerHost.swift` |
|
||||
| `cpp/pstr_mpv.cpp` | `Player/MpvPlayer.swift`, `Player/StreamProtocol.swift` |
|
||||
|
||||
@@ -79,6 +80,11 @@ Each of these is the platform's doing, not a choice to diverge.
|
||||
place of Android's `WatchSyncWorker`, and every five minutes while it is open.
|
||||
- **Background audio off pauses** the episode when the app leaves the screen, where
|
||||
Android stops it.
|
||||
- **New-episode checks run when iOS chooses.** Android's `NewEpisodesWorker`
|
||||
crawls every six hours on a network. iOS's `BGProcessingTask` is asked for no
|
||||
sooner than six hours, and the system decides when, mostly overnight on a
|
||||
charger. An app refresh would come more often but gets about thirty seconds,
|
||||
which a crawl of a large share does not fit in.
|
||||
- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's
|
||||
`.plain` button style and `onTapGesture` show none, which on a slow iPad reads as
|
||||
a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Android and iOS
|
||||
|
||||
The Android and iOS apps share the desktop app's engine, so a library, its watch history and its
|
||||
settings behave the same everywhere. This page covers what is different on a phone, a tablet and
|
||||
a TV. [Installation](INSTALL.md) explains how to install each one.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="../images/android-home.png" alt="Home" width="200"></td>
|
||||
<td><img src="../images/android-library.png" alt="All titles" width="200"></td>
|
||||
<td><img src="../images/android-title.png" alt="A title page" width="200"></td>
|
||||
<td><img src="../images/android-episodes.png" alt="Episodes and downloads" width="200"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Adding a share
|
||||
|
||||
Tap **Add share** on the **Shares** tab, paste the link, and add a custom password if the link has
|
||||
one. The app checks the link before it saves it.
|
||||
|
||||
- **Android:** you can also share a link to the app from your browser or a chat app, or open a
|
||||
`drive.proton.me/urls/…` link directly if you allow the app to open supported links in the
|
||||
system settings.
|
||||
- **iOS:** there is no share sheet. Copy the link, then tap **Paste link** in the dialog.
|
||||
|
||||
Folders of your own Drive work as on the desktop: sign in on the **Shares** tab, then **Add from
|
||||
your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||
|
||||
## The player
|
||||
|
||||
- Tap to show or hide the controls.
|
||||
- **Double-tap** the left or right third of the screen to go back or forward 10 seconds. Double-tap
|
||||
the middle to pause.
|
||||
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and
|
||||
**Lock to landscape**.
|
||||
- Sound keeps playing with the screen off or the app in the background, with controls on the lock
|
||||
screen. Turn off **Background audio** in Settings to stop that.
|
||||
|
||||
Picture-in-Picture is not available on either platform.
|
||||
|
||||
## Android TV
|
||||
|
||||
The app appears in the TV launcher. With the controls hidden:
|
||||
|
||||
| Remote | Does |
|
||||
|---|---|
|
||||
| Left / right | Back or forward 10 seconds |
|
||||
| Centre, up or down | Show the controls |
|
||||
| Play/pause | Pause and play |
|
||||
|
||||
There is no separate TV layout yet, and the app has not been tested on a real TV. Reports are
|
||||
welcome.
|
||||
|
||||
## Downloads
|
||||
|
||||
Download an episode, a season or a show from its title page, and play it from the **Downloads**
|
||||
tab without a connection.
|
||||
|
||||
- **Android** waits for Wi-Fi unless you turn off **Download on Wi-Fi only**. Downloads continue
|
||||
with the app closed.
|
||||
- **iOS** downloads only while the app is open, and for the short time iOS allows after you leave
|
||||
it. A stopped download resumes where it stopped.
|
||||
|
||||
**Delete all offline episodes** in Settings frees the space in one step.
|
||||
|
||||
## New episodes
|
||||
|
||||
When a share gains episodes of a show, the app sends a notification.
|
||||
|
||||
- **Android** checks about every 6 hours, when the device has a network and the battery is not
|
||||
low. Turn it off with **New episode notifications** in Settings.
|
||||
- **iOS** asks the system to check every 6 hours or so; iOS decides when it actually runs.
|
||||
|
||||
## Where your secrets are kept
|
||||
|
||||
- **Android:** in the app's private storage, encrypted with a key in the Android Keystore. If you
|
||||
change or remove the device's screen lock, Android may delete that key. The app then asks you
|
||||
to **Re-enter link** for each share.
|
||||
- **iOS:** in the Keychain, only on this device, and not in backups.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Android 12** or newer, on an arm64 or x86_64 device. 32-bit devices are not supported.
|
||||
- **iOS 17** or newer, on iPhone or iPad.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Privacy
|
||||
|
||||
proton-stream has no server and no account with its developer. Each app talks to Proton directly,
|
||||
and to nothing else unless you turn on posters and descriptions. This page lists what goes where.
|
||||
|
||||
## What the app sends, and to whom
|
||||
|
||||
| To | What | When |
|
||||
|---|---|---|
|
||||
| Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always |
|
||||
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
|
||||
|
||||
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
|
||||
|
||||
**Posters and descriptions** are off by default. When they are on, the provider learns the names
|
||||
of the titles in your library. It does not learn your files, your watch history or your shares.
|
||||
Turning them off deletes every stored answer.
|
||||
|
||||
## Secrets
|
||||
|
||||
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
|
||||
a password.
|
||||
- **A link's own password**, if it has one.
|
||||
- **Your Proton session**, if you signed in. The app keeps the session, not your password.
|
||||
|
||||
All of these go into the system's credential store, never into a config file:
|
||||
|
||||
| Platform | Store |
|
||||
|---|---|
|
||||
| Linux | Secret Service (GNOME Keyring, KWallet, KeePassXC) |
|
||||
| Windows | Credential Manager |
|
||||
| Android | App storage, encrypted with a key in the Android Keystore |
|
||||
| iOS | Keychain, this device only |
|
||||
|
||||
If you sign in, your shares, with their links and passwords, also sync through your own Drive.
|
||||
Like every file there, they are end-to-end encrypted. See [Account and sync](ACCOUNT_AND_SYNC.md).
|
||||
|
||||
## How the player gets the video
|
||||
|
||||
The player is fed through a callback inside the app's own process. There is no local web server,
|
||||
so there is no port another program on the computer could ask for the decrypted video. The
|
||||
player is handed an opaque token, not a link, so no secret ends up in its log or window title.
|
||||
|
||||
## What stays on your device
|
||||
|
||||
| What | Where on Linux |
|
||||
|---|---|
|
||||
| Settings and the share list (without secrets) | `~/.config/proton-stream/` |
|
||||
| The library and watch history (`catalog.db`) and downloads (`offline/`) | `~/.local/share/proton-stream/` |
|
||||
| Streamed video (`blocks/`), thumbnails and posters | `~/.cache/proton-stream/` |
|
||||
|
||||
On Windows the same folders are under `%APPDATA%\narl\proton-stream\` and
|
||||
`%LOCALAPPDATA%\narl\proton-stream\`. On macOS they are under `~/Library/Application Support/`
|
||||
and `~/Library/Caches/`, in `io.narl.proton-stream`.
|
||||
|
||||
**The streaming cache and downloads hold decrypted video.** Anyone who can read your user's files
|
||||
can play them. Empty the cache in **Settings → Storage**, and delete downloads on the
|
||||
**Downloads** page.
|
||||
|
||||
The library database holds file names and your watch history, in plain text. It holds no
|
||||
secrets.
|
||||
|
||||
## Other people on the same computer
|
||||
|
||||
- A share link given on the command line, as in `proton-stream 'https://…#key'`, is visible in
|
||||
the process list while the command runs, and may land in your shell history. Paste links into
|
||||
the window instead.
|
||||
- Your desktop's media controls are given the title, the episode and a picture already on disk.
|
||||
They are never given a provider link, so the desktop does not contact a provider by itself.
|
||||
@@ -0,0 +1,41 @@
|
||||
# proton-stream documentation
|
||||
|
||||
proton-stream turns the videos in your Proton Drive, and the share links people send you, into
|
||||
a library you can browse and stream. It runs on Linux, Windows, Android, Android TV and iOS.
|
||||
This is an unofficial client, not a Proton product.
|
||||
|
||||
<img src="../images/desktop-home.png" alt="The library in the desktop app" width="820">
|
||||
|
||||
## Start here
|
||||
|
||||
- [Getting started](GETTING_STARTED.md): install, add your first share, press play.
|
||||
- [Installation](INSTALL.md): every platform, and how to update and uninstall.
|
||||
- [FAQ](FAQ.md): short answers to the questions people ask first.
|
||||
|
||||
## Using it
|
||||
|
||||
- [User guide](USER_GUIDE.md): the library, title pages, the player, downloads, history and
|
||||
settings.
|
||||
- [Account and sync](ACCOUNT_AND_SYNC.md): signing in, folders of your own Drive, and keeping
|
||||
your place on every device.
|
||||
- [Android and iOS](MOBILE.md): what is different on a phone, a tablet and Android TV.
|
||||
|
||||
## Reference
|
||||
|
||||
- [Command-line reference](CLI.md): the `pstr` tool and the `proton-stream` launcher.
|
||||
- [Privacy](PRIVACY.md): what goes where, and what stays on your device.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
- [Troubleshooting](TROUBLESHOOTING.md): links that will not add, sign-in problems, playback
|
||||
problems, and where the logs are.
|
||||
|
||||
## Contributing
|
||||
|
||||
- [Development](DEVELOPMENT.md): the state of each layer, the build order and the traps.
|
||||
- [Architecture](ARCHITECTURE.md): the crates, the data flow when an episode plays, and where
|
||||
secrets live.
|
||||
- [Testing](TESTING.md), [Metadata](METADATA.md), [Design tokens](DESIGN-TOKENS.md),
|
||||
[Android development](ANDROID.md) and [iOS development](IOS.md).
|
||||
|
||||
Bugs are tracked in [BUGS.md](BUGS.md) in the repository.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Adding a share
|
||||
|
||||
### "Proton share URL has no #password fragment; copy the full share link"
|
||||
|
||||
The link is missing the part after `#`, which is its decryption key. Copy the link again from
|
||||
where you got it, all of it.
|
||||
|
||||
### "This link has a password of its own. Enter it to add the link."
|
||||
|
||||
The person who shared it also set a password. Turn on **The link asks for a password** (on a
|
||||
phone, fill in **Custom password**) and type it.
|
||||
|
||||
### "That share is already added."
|
||||
|
||||
The link, or the same folder of your Drive, is already on the **Shares** page.
|
||||
|
||||
### "Last crawl failed: …"
|
||||
|
||||
The share could not be read. The link may have expired or been revoked, or its password
|
||||
changed. If the password changed, choose **Re-enter link**.
|
||||
|
||||
## The keyring
|
||||
|
||||
### "share … has no stored credentials; remove and re-add it"
|
||||
|
||||
The link and password are gone from the keyring. This happens when a keyring is reset, or on
|
||||
Android when the screen lock changes. Choose **Re-enter link** on the **Shares** page. Your
|
||||
library, history and downloads stay.
|
||||
|
||||
### "credential store: …" on Linux
|
||||
|
||||
The app found no Secret Service. Install and start one, such as GNOME Keyring, KWallet or
|
||||
KeePassXC with its Secret Service integration, and unlock it.
|
||||
|
||||
## Signing in
|
||||
|
||||
### "Proton did not accept that address and password"
|
||||
|
||||
Check both. Sign in on proton.me to make sure the account works.
|
||||
|
||||
### "Proton has blocked sign-ins from this app for now"
|
||||
|
||||
Proton's abuse protection stopped the sign-in. Wait a while and try again, or sign in once on
|
||||
proton.me first.
|
||||
|
||||
### "This account's only second factor is a security key"
|
||||
|
||||
The app cannot use security keys yet. Add an authenticator app as a second method in your
|
||||
Proton account settings, then sign in with its code.
|
||||
|
||||
### The CAPTCHA window does not open on Linux
|
||||
|
||||
The window needs WebKitGTK 4.1. The `.deb`, `.rpm` and Arch packages pull it in; with the
|
||||
tarball, install it yourself.
|
||||
|
||||
### `pstr login` asks for a CAPTCHA
|
||||
|
||||
The command line cannot show one. Sign in once in the app.
|
||||
|
||||
## Playback
|
||||
|
||||
### Green blocks, a black picture or torn frames
|
||||
|
||||
Turn off **Hardware decoding** in Settings. It applies from the next file.
|
||||
|
||||
### An episode takes long to start
|
||||
|
||||
The first seconds of a file are fetched over the network. On a slow connection, start the
|
||||
episode and give it a moment, or download it first. A second viewing comes from the disk cache.
|
||||
|
||||
### No skip button for the opening
|
||||
|
||||
The skip button comes from the file's chapter names. A file without named chapters gets none.
|
||||
|
||||
### The wrong subtitle track plays
|
||||
|
||||
Choose the track in the player. The app remembers it for the rest of the show. Set the
|
||||
language to start in under **Settings → Playback**.
|
||||
|
||||
## Windows
|
||||
|
||||
### The app does not start from the portable `.zip`
|
||||
|
||||
Keep every file from the archive in one folder. The app needs `libmpv-2.dll` and
|
||||
`WebView2Loader.dll` next to `proton-stream.exe`.
|
||||
|
||||
### SmartScreen warns about the installer
|
||||
|
||||
The binaries are not code-signed yet. Choose **More info**, then **Run anyway**.
|
||||
|
||||
### A pinned taskbar icon shows a blank picture after an update
|
||||
|
||||
Unpin it and pin it again once.
|
||||
|
||||
## Linux
|
||||
|
||||
### "Not responding" when the window is on another workspace
|
||||
|
||||
Fixed. On Wayland the app limits its own frame rate instead of waiting for vsync, which a
|
||||
compositor withholds from a window it is not showing. Update to the latest release.
|
||||
|
||||
## Logs
|
||||
|
||||
Start the app from a terminal to see its log:
|
||||
|
||||
```bash
|
||||
RUST_LOG=debug proton-stream
|
||||
```
|
||||
|
||||
The same works for `pstr`. When you
|
||||
[open an issue](https://github.com/narrrl/proton-stream/issues), include the lines around the
|
||||
error, but **remove any share link first**: the part after `#` is a key.
|
||||
@@ -0,0 +1,198 @@
|
||||
# User guide
|
||||
|
||||
This page describes the desktop app. The Android and iOS apps have the same pages as tabs at the
|
||||
bottom of the screen; [Android and iOS](MOBILE.md) covers what is different there.
|
||||
|
||||
The app has five pages, in the bar at the top: **Library**, **History**, **Shares**,
|
||||
**Downloads** and **Settings**. **Ctrl+1** to **Ctrl+5** open them.
|
||||
|
||||
## Library
|
||||
|
||||
<img src="../images/desktop-library.png" alt="The whole library, filtered and sorted" width="820">
|
||||
|
||||
The library shows every title from every share in one place. Two shares that hold the same show
|
||||
show it once.
|
||||
|
||||
- **The banner** at the top shows the title you watched last, with **Resume** and **More info**.
|
||||
- **Continue watching** has the episode you stopped in for each show, or the next one when you
|
||||
finished an episode. To take a show off this row, right-click it and choose **Remove from
|
||||
Continue watching**.
|
||||
- **Shelves** such as **Airing now**, **Directed by Satoshi Kon** or **From MADHOUSE** appear when
|
||||
posters and descriptions are on. The app shows at most four, and only for a director, studio or
|
||||
genre with at least three titles.
|
||||
- **All titles** is the full grid. Choose a filter and a sort order above it.
|
||||
|
||||
| Filters | Sort orders |
|
||||
|---|---|
|
||||
| All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular |
|
||||
|
||||
**Group franchises** folds a series, its sequels and its films into one tile with a **+N** badge.
|
||||
It is on by default, and needs posters and descriptions to know what belongs together.
|
||||
|
||||
**Search** with **Ctrl+F** or **/**. It matches every name a title has: the English one, the
|
||||
romaji one and the name of its folder.
|
||||
|
||||
**Refresh** (or **F5**) reads every share again. The app also does this by itself when a share
|
||||
has not been read for 6 hours.
|
||||
|
||||
### How the library reads your folders
|
||||
|
||||
The folder decides which show a file belongs to. The file name gives the season and episode.
|
||||
|
||||
| Layout | Read as |
|
||||
|---|---|
|
||||
| `Frieren/Season 01/S01E05.mkv` | Frieren, season 1, episode 5 |
|
||||
| `Frieren/S01E05 - The Hero's Statue.mkv` | The same, with the episode title |
|
||||
| `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 |
|
||||
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
|
||||
| `Akira (1988).mkv` | A film, from 1988 |
|
||||
|
||||
- Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`.
|
||||
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
|
||||
the folder above them is.
|
||||
- An episode title is read only after a dash, as in `S01E01 - Name`.
|
||||
- A file with no episode number is a film.
|
||||
|
||||
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
|
||||
containers.
|
||||
|
||||
## A title's page
|
||||
|
||||
Click a title to open its page. **Esc** goes back.
|
||||
|
||||
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
|
||||
the beginning.
|
||||
- **Download show** keeps every episode on this device. See [Downloads](#downloads).
|
||||
- **Match…** opens the matcher, where you choose which AniList or TMDB entry the title is. Use it
|
||||
when the app guessed wrong or did not guess at all. A match you pick is never replaced.
|
||||
- **More on AniList** (or TMDB) opens the title's page there.
|
||||
- Each season has **Mark season watched** and **Download season**.
|
||||
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark
|
||||
watched up to here** to tick it and every episode before it.
|
||||
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
|
||||
|
||||
An episode counts as watched once you have seen 90% of it.
|
||||
|
||||
## The player
|
||||
|
||||
The picture plays inside the app's window. Move the mouse to show the controls.
|
||||
|
||||
- **Skip opening** and **Skip ending** appear when the file's chapters mark an opening, an ending
|
||||
or a preview. Files without chapter names get no skip button. Turn on **Skip openings and
|
||||
credits** in Settings to skip them without asking.
|
||||
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
|
||||
**Play now**, **Cancel** or **Watch till the end**.
|
||||
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your
|
||||
choice for each show.
|
||||
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
|
||||
|
||||
### Keyboard
|
||||
|
||||
Press **?** anywhere to see this list in the app.
|
||||
|
||||
| Key | Anywhere |
|
||||
|---|---|
|
||||
| Ctrl+F or / | Search the library |
|
||||
| F5 or Ctrl+R | Read every share again |
|
||||
| Ctrl+1 – 5 | Library, History, Shares, Downloads, Settings |
|
||||
| Ctrl+, | Settings |
|
||||
| Ctrl+V | Paste a share link to add it |
|
||||
| Tab or arrows | Move between titles and episodes |
|
||||
| Enter | Open or play what is highlighted |
|
||||
| Alt+← or mouse back | Back |
|
||||
|
||||
| Key | Playing |
|
||||
|---|---|
|
||||
| Space or K | Pause and play |
|
||||
| ← / → | Back 10 s, forward 30 s |
|
||||
| ↑ / ↓ | Volume |
|
||||
| M | Mute |
|
||||
| N / P | Next and previous episode |
|
||||
| [ / ] | Slower and faster |
|
||||
| S | Skip the opening or the credits |
|
||||
| E | The list of episodes |
|
||||
| F or double-click | Fullscreen |
|
||||
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
|
||||
| Esc | Leave fullscreen, then the player |
|
||||
|
||||
Media keys and your desktop's media controls work too.
|
||||
|
||||
### Subtitle files beside the video
|
||||
|
||||
The app picks up `.srt`, `.ass`, `.ssa` and `.vtt` files next to a video:
|
||||
|
||||
```text
|
||||
Frieren/Season 01/
|
||||
S01E01.mkv
|
||||
S01E01.en.srt English
|
||||
S01E01.en.forced.ass English, "forced"
|
||||
Subs/
|
||||
S01E01/
|
||||
2_English.srt English
|
||||
```
|
||||
|
||||
A `Subs` folder beside the videos works, and so does any subtitle in a folder that holds a single
|
||||
video. The language comes from the last word in the name that names one.
|
||||
|
||||
### Dialogue, not signs and songs
|
||||
|
||||
Many anime releases have two subtitle tracks in one language: the full dialogue, and a short
|
||||
*Signs & Songs* track that only translates on-screen text. When a file would start on the short
|
||||
track, the app switches to the full one in the same language.
|
||||
|
||||
## History
|
||||
|
||||
Everything you played, newest first, grouped by day. An entry says **Watched**, **Stopped at
|
||||
12:04 of 24:00** or **Played**. Removing an entry marks a watched episode unwatched, or forgets
|
||||
where you stopped in one you had not finished.
|
||||
|
||||
## Downloads
|
||||
|
||||
A download keeps a full copy of an episode on this device, to play without a connection. Start
|
||||
one from a title's page: a whole show, a season or a single episode.
|
||||
|
||||
The **Downloads** page lists them with **Pause**, **Resume**, **Cancel**, **Retry**, **Play** and
|
||||
**Delete**. Three run at a time. Downloads do not count against the streaming cache.
|
||||
|
||||
## Shares
|
||||
|
||||
The **Shares** page lists every share, with what it holds and when it was last read.
|
||||
|
||||
- **Crawl** reads the share again.
|
||||
- **Rename** changes the name shown in the app.
|
||||
- **Re-enter link** gives the app the link again, for a keyring that lost it or a password that
|
||||
changed. The library, history and downloads stay.
|
||||
- **Remove** takes the share's titles out of the library, deletes its downloads and removes its
|
||||
link and password from the keyring.
|
||||
|
||||
The **Proton account** section on this page is where you sign in. See
|
||||
[Account and sync](ACCOUNT_AND_SYNC.md).
|
||||
|
||||
## Settings
|
||||
|
||||
**Appearance**
|
||||
|
||||
- **Palette:** Proton (the near-black default), Catppuccin Latte, Frappé, Macchiato and Mocha, or
|
||||
Persona 5.
|
||||
- **Accent:** Mauve, Pink, Sky, Pink → Sky, Lavender, Blue, Teal, Peach or Red. **Gradient
|
||||
accent** draws it as a gradient.
|
||||
- **Text size:** 90% to 150%. This one stays on this computer.
|
||||
- **Library tiles:** posters or stills. **Title names:** as in the share, English or romaji.
|
||||
|
||||
**Playback**
|
||||
|
||||
- **Audio language** and **Subtitles:** the language to start in, or the file's own default.
|
||||
- **Play the next episode** (on by default) and **Skip openings and credits** (off).
|
||||
- **Hardware decoding** (on). Turn it off if the picture shows green blocks or stays black.
|
||||
|
||||
**Storage**
|
||||
|
||||
- **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,
|
||||
so watching again needs no network. **Clear the streaming cache** empties it.
|
||||
|
||||
**Posters and descriptions**
|
||||
|
||||
- Off by default. When it is on, the app sends the names of your titles to the provider you pick:
|
||||
**AniList** (no key needed) or **TMDB** (needs a free API key). See [Privacy](PRIVACY.md).
|
||||
- **Match the library** looks up new titles now. **Match everything again** looks up every title,
|
||||
except those you matched by hand.
|
||||
|
After Width: | Height: | Size: 653 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="proton-stream">
|
||||
<defs>
|
||||
<linearGradient id="ps-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A48BFF"/>
|
||||
<stop offset="0.55" stop-color="#7552FF"/>
|
||||
<stop offset="1" stop-color="#5B36F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ps-glyph" x1="0" y1="36" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#ECE6FF"/>
|
||||
</linearGradient>
|
||||
<!-- The play mark, cut into the blocks a revision is fetched in: each one
|
||||
decrypts on its own, which is the whole reason this app can seek. -->
|
||||
<mask id="ps-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||
<path d="M50 41.5v41a6 6 0 0 0 9.1 5.1l34.2-20.5a6 6 0 0 0 0-10.3L59.1 36.4A6 6 0 0 0 50 41.5z" fill="#FFFFFF"/>
|
||||
<g fill="#000000">
|
||||
<rect x="63" y="0" width="5.5" height="128"/>
|
||||
<rect x="78.5" y="0" width="5.5" height="128"/>
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<!-- base: a rounded tile with a darker lower edge for depth -->
|
||||
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
|
||||
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- glyph shadow, then the glyph itself. The mark's box centres on x=73 and
|
||||
its centroid on x=66; it sits at 70 -> 64, between the two, because the
|
||||
box centre reads as too far left and the centroid as too far right. The
|
||||
Android and iOS icons centre it the same way. -->
|
||||
<g transform="translate(-6 0)">
|
||||
<g transform="translate(0 3)">
|
||||
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
||||
</g>
|
||||
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -38,14 +38,18 @@ struct WatchSnapshot: Equatable {
|
||||
final class AppModel {
|
||||
var loading = true
|
||||
var refreshing = false
|
||||
/// A crawl is running, which the banner offers to stop.
|
||||
private(set) var crawling = false
|
||||
private(set) var query = ""
|
||||
var titles: [TitleRecord] = []
|
||||
/// The grid: `titles` ordered, each franchise folded into one tile when
|
||||
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
|
||||
/// when the grid falls back to `titles` as they are.
|
||||
var arrangement: ArrangementRecord?
|
||||
var sort: LibrarySort = .name
|
||||
var grouped = true
|
||||
/// As the viewer last left them.
|
||||
var filter: LibraryFilter = SettingsStore().libraryFilter
|
||||
var sort: LibrarySort = SettingsStore().librarySort
|
||||
var grouped = SettingsStore().libraryGrouped
|
||||
var shares: [ShareRecord] = []
|
||||
var offline: [OfflineRecord] = []
|
||||
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
|
||||
@@ -67,6 +71,10 @@ final class AppModel {
|
||||
/// Every banner line whose work is still running, oldest first.
|
||||
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
|
||||
@ObservationIgnored private var nextActivity = 0
|
||||
/// Crawls running now.
|
||||
@ObservationIgnored private var crawlsRunning = 0
|
||||
/// Set by `stopCrawl`, so a queue of shares stops rather than going on to the next.
|
||||
@ObservationIgnored private var crawlStopped = false
|
||||
|
||||
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||
/// listing, and no upload.
|
||||
@@ -105,6 +113,16 @@ final class AppModel {
|
||||
func arrange(_ sort: LibrarySort, _ grouped: Bool) {
|
||||
self.sort = sort
|
||||
self.grouped = grouped
|
||||
let settings = SettingsStore()
|
||||
settings.librarySort = sort
|
||||
settings.libraryGrouped = grouped
|
||||
Task { await reloadLibrary(query) }
|
||||
}
|
||||
|
||||
/// Narrow the grid to some titles, and keep the choice for the next launch.
|
||||
func setFilter(_ filter: LibraryFilter) {
|
||||
self.filter = filter
|
||||
SettingsStore().libraryFilter = filter
|
||||
Task { await reloadLibrary(query) }
|
||||
}
|
||||
|
||||
@@ -133,6 +151,30 @@ final class AppModel {
|
||||
Task { await crawlNow(shareId) }
|
||||
}
|
||||
|
||||
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
|
||||
///
|
||||
/// Held under a background task: a crawl takes minutes, and without one
|
||||
/// iOS suspends the app the moment it leaves the screen and tears down its
|
||||
/// connections, so a glance at another app failed the crawl.
|
||||
private func crawlShares(_ id: String?) async throws {
|
||||
crawlsRunning += 1
|
||||
crawling = true
|
||||
let task = BackgroundTask(name: "crawl")
|
||||
defer {
|
||||
task.end()
|
||||
crawlsRunning -= 1
|
||||
crawling = crawlsRunning > 0
|
||||
}
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
}
|
||||
|
||||
/// Stop the crawls running now. Shares already read stay in the library;
|
||||
/// the one being read keeps what it had.
|
||||
func stopCrawl() {
|
||||
crawlStopped = true
|
||||
Task { try? await NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||
/// this returns.
|
||||
func crawlNow(_ shareId: String?) async {
|
||||
@@ -140,7 +182,7 @@ final class AppModel {
|
||||
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
||||
message = nil
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||
try await crawlShares(shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
@@ -231,18 +273,22 @@ final class AppModel {
|
||||
guard !ids.isEmpty else { return }
|
||||
let before = Set(titles.map(\.key))
|
||||
var failed: [String] = []
|
||||
crawlStopped = false
|
||||
for id in ids {
|
||||
if crawlStopped { break }
|
||||
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
||||
await busy("Reading \(name)…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
try await crawlShares(id)
|
||||
} catch {
|
||||
failed.append("\(name): \(errorMessage(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
await reloadNow()
|
||||
if !failed.isEmpty {
|
||||
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||
// the share's row rather than in their face, and only news is said.
|
||||
if !failed.isEmpty, announce {
|
||||
message = failed.joined(separator: "\n")
|
||||
return
|
||||
}
|
||||
@@ -250,6 +296,9 @@ final class AppModel {
|
||||
if announce {
|
||||
let added = Set(titles.map(\.key)).subtracting(before).count
|
||||
message = added == 1 ? "1 title added" : "\(added) titles added"
|
||||
} else {
|
||||
let files = shares.filter { ids.contains($0.id) }.reduce(UInt64(0)) { $0 + ($1.crawlAdded ?? 0) }
|
||||
if files > 0 { message = files == 1 ? "1 new file" : "\(files) new files" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +312,7 @@ final class AppModel {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
try await crawlShares(id)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
@@ -273,6 +322,18 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a share something else. The library, history and downloads stay.
|
||||
func renameShare(id: String, name: String) {
|
||||
Task {
|
||||
do {
|
||||
_ = try await run { try $0.renameShare(shareId: id, name: name) }
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
func removeShare(_ id: String) {
|
||||
Task {
|
||||
let name = shares.first { $0.id == id }?.name ?? "the share"
|
||||
@@ -430,9 +491,10 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// On launch and back on screen: crawl shares the catalog has nothing of —
|
||||
/// brought by a sync that ran in the background, or an add whose crawl
|
||||
/// never finished.
|
||||
/// On launch and back on screen: crawl shares due a look — ones the
|
||||
/// catalog has nothing of, brought by a sync that ran in the background or
|
||||
/// an add whose crawl never finished, and ones not crawled for six hours,
|
||||
/// so new episodes turn up without a pull to refresh.
|
||||
func enteredForeground() {
|
||||
guard !catchingUp else { return }
|
||||
catchingUp = true
|
||||
@@ -478,8 +540,15 @@ final class AppModel {
|
||||
/// title's positions are what this throws away, and a mis-tap on a tile's
|
||||
/// menu should not cost them.
|
||||
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
|
||||
showWatched(title.playlist, watched)
|
||||
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
|
||||
markEpisodes(title.playlist, watched, title.displayName)
|
||||
}
|
||||
|
||||
/// Some episodes seen or unseen — a season, or a whole title — with an
|
||||
/// Undo; `what` names them in the message.
|
||||
func markEpisodes(_ episodes: [EpisodeRecord], _ watched: Bool, _ what: String) {
|
||||
let episodes = episodes.filter { $0.watched != watched }
|
||||
showWatched(episodes, watched)
|
||||
changeWatch(episodes, "\(what) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
|
||||
let duration = before?.durationSecs
|
||||
try engine.saveWatchState(
|
||||
shareId: episode.shareId,
|
||||
@@ -516,13 +585,7 @@ final class AppModel {
|
||||
do {
|
||||
try await run { engine in
|
||||
for snapshot in snapshots {
|
||||
try engine.saveWatchState(
|
||||
shareId: snapshot.shareId,
|
||||
linkId: snapshot.linkId,
|
||||
positionSecs: snapshot.state?.positionSecs ?? 0,
|
||||
durationSecs: snapshot.state?.durationSecs,
|
||||
watched: snapshot.state?.watched ?? false
|
||||
)
|
||||
try engine.restoreWatchState(shareId: snapshot.shareId, linkId: snapshot.linkId, state: snapshot.state)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -692,25 +755,9 @@ final class AppModel {
|
||||
/// Unwatching rewinds, matching the desktop client. Marking one seen puts
|
||||
/// the position at its duration where one is known, so the progress bar
|
||||
/// agrees with the tick.
|
||||
/// One episode's tick, with the same Undo a season's has.
|
||||
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
|
||||
showWatched([episode], watched)
|
||||
Task {
|
||||
do {
|
||||
try await run { engine in
|
||||
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
|
||||
try engine.saveWatchState(
|
||||
shareId: episode.shareId,
|
||||
linkId: episode.linkId,
|
||||
positionSecs: watched ? duration ?? 0 : 0,
|
||||
durationSecs: duration,
|
||||
watched: watched
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
markEpisodes([episode], watched, episode.label)
|
||||
}
|
||||
|
||||
/// Draw a watched mark the moment it is tapped, before the bridge has
|
||||
@@ -750,6 +797,7 @@ final class AppModel {
|
||||
/// `reload`, for a caller that goes on to read what it loaded.
|
||||
func reloadNow() async {
|
||||
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
|
||||
let filter = filter
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
do {
|
||||
@@ -761,7 +809,7 @@ final class AppModel {
|
||||
return Reloaded(
|
||||
shares: try engine.shares(),
|
||||
titles: try engine.library(search: query),
|
||||
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
|
||||
arrangement: try engine.arrangement(search: query, filter: filter, sort: sort, grouped: grouped),
|
||||
offline: try engine.offlineFiles(),
|
||||
metadataSettings: try engine.metadataSettings(),
|
||||
storage: try engine.storageUsage()
|
||||
@@ -782,11 +830,12 @@ final class AppModel {
|
||||
|
||||
private func reloadLibrary(_ query: String) async {
|
||||
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
|
||||
let filter = filter
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
do {
|
||||
let (titles, arrangement) = try await run { engine in
|
||||
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped))
|
||||
(try engine.library(search: search), try engine.arrangement(search: search, filter: filter, sort: sort, grouped: grouped))
|
||||
}
|
||||
self.titles = titles
|
||||
self.arrangement = arrangement
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import BackgroundTasks
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/// Look at the shares while the app is away, and say when a show gained
|
||||
/// episodes — what `NewEpisodesWorker` does on Android.
|
||||
///
|
||||
/// A processing task rather than an app refresh: a crawl of a large share
|
||||
/// takes minutes, and an app refresh gets about thirty seconds. iOS runs it
|
||||
/// when it sees fit, mostly overnight on a charger, so it is a best effort
|
||||
/// where Android's is a schedule.
|
||||
enum NewEpisodes {
|
||||
static let identifier = "io.narl.protonstream.new-episodes"
|
||||
private static let interval: TimeInterval = 6 * 60 * 60
|
||||
|
||||
/// At launch, before the app finishes launching: iOS refuses a handler
|
||||
/// registered later.
|
||||
static func register() {
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
||||
guard let task = task as? BGProcessingTask else { return }
|
||||
run(task)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the next look scheduled, or cancel it when the viewer turned it off.
|
||||
static func schedule() {
|
||||
guard SettingsStore().newEpisodeNotifications else {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: identifier)
|
||||
return
|
||||
}
|
||||
let request = BGProcessingTaskRequest(identifier: identifier)
|
||||
request.requiresNetworkConnectivity = true
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
|
||||
try? BGTaskScheduler.shared.submit(request)
|
||||
}
|
||||
|
||||
/// Ask once, when the viewer has the setting on; iOS remembers the answer.
|
||||
static func requestPermission() {
|
||||
guard SettingsStore().newEpisodeNotifications else { return }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
|
||||
}
|
||||
|
||||
private static func run(_ task: BGProcessingTask) {
|
||||
schedule()
|
||||
let work = Task {
|
||||
let news = (try? await NativeRuntime.engine().crawlForNews()) ?? []
|
||||
await post(news)
|
||||
task.setTaskCompleted(success: !Task.isCancelled)
|
||||
}
|
||||
task.expirationHandler = {
|
||||
work.cancel()
|
||||
Task { try? await NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
}
|
||||
|
||||
private static func post(_ news: [NewEpisodesRecord]) async {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
guard await center.notificationSettings().authorizationStatus == .authorized else { return }
|
||||
for item in news {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = item.title
|
||||
content.body = describe(item)
|
||||
content.threadIdentifier = "new-episodes"
|
||||
// Keyed by the show, so a second look's news replaces the first.
|
||||
let request = UNNotificationRequest(identifier: "new-episodes-\(item.titleKey)", content: content, trigger: nil)
|
||||
try? await center.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one show's notification says: "S01E04 and 2 more are new".
|
||||
static func describe(_ news: NewEpisodesRecord) -> String {
|
||||
let more = Int(news.count) - 1
|
||||
guard let first = news.first else {
|
||||
return news.count == 1 ? "1 new episode" : "\(news.count) new episodes"
|
||||
}
|
||||
switch more {
|
||||
case 0: return "\(first) is new"
|
||||
case 1: return "\(first) and 1 more are new"
|
||||
default: return "\(first) and \(more) more are new"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,13 @@ struct ProtonStreamApp: App {
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
NewEpisodes.register()
|
||||
NewEpisodes.schedule()
|
||||
NewEpisodes.requestPermission()
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
MainActor.assumeIsolated { OrientationLock.mask }
|
||||
}
|
||||
@@ -107,6 +114,9 @@ struct RootView: View {
|
||||
.animation(.easeInOut(duration: 0.2), value: model.activity)
|
||||
}
|
||||
.background(scheme.background.ignoresSafeArea())
|
||||
// Covered by the player, but still there to VoiceOver, which
|
||||
// would otherwise walk the library behind the picture.
|
||||
.accessibilityHidden(host.isOpen && !host.minimized)
|
||||
}
|
||||
.overlay {
|
||||
// Over the shell rather than instead of it: the page behind stays
|
||||
@@ -181,9 +191,14 @@ struct RootView: View {
|
||||
selectedTitleKey = title.key
|
||||
})
|
||||
case .shares:
|
||||
SharesView(incomingLink: $incomingLink)
|
||||
// The first share is the library: watch it fill rather than stay
|
||||
// on a form that has done its job.
|
||||
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
|
||||
case .downloads:
|
||||
DownloadsView()
|
||||
DownloadsView(onPlay: play, onOpen: { title in
|
||||
destination = .library
|
||||
selectedTitleKey = title.key
|
||||
})
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
@@ -194,7 +209,16 @@ struct RootView: View {
|
||||
/// tap rather than back and in again.
|
||||
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
|
||||
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
|
||||
let library = LibraryView(onResume: play, onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } })
|
||||
let library = LibraryView(
|
||||
onResume: play,
|
||||
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } },
|
||||
// The form as a pasted link opens it, only with nothing in it.
|
||||
onAddShare: {
|
||||
incomingLink = ""
|
||||
destination = .shares
|
||||
},
|
||||
onOpenSettings: { select(.settings) }
|
||||
)
|
||||
if width >= twoPaneWidth {
|
||||
HStack(spacing: 0) {
|
||||
library.frame(width: width * 0.45)
|
||||
@@ -321,13 +345,18 @@ private struct ActivityBanner: View {
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// A crawl of a large share can take minutes, and is the
|
||||
// viewer's to call off; what it has read so far stays.
|
||||
if model.crawling {
|
||||
Button("Stop", action: model.stopCrawl).buttonStyle(.quiet)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||
.padding(.horizontal, 12)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityElement(children: model.crawling ? .contain : .combine)
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,45 @@ struct SettingsStore {
|
||||
|
||||
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 }
|
||||
|
||||
/// How the library was last sorted; `.name` until the viewer chooses.
|
||||
var librarySort: LibrarySort {
|
||||
get {
|
||||
let stored = defaults.string(forKey: Key.librarySort)
|
||||
return Self.sorts.first { String(describing: $0) == stored } ?? .name
|
||||
}
|
||||
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) }
|
||||
}
|
||||
|
||||
/// Which titles the library last showed.
|
||||
var libraryFilter: LibraryFilter {
|
||||
get {
|
||||
let stored = defaults.string(forKey: Key.libraryFilter)
|
||||
return Self.filters.first { String(describing: $0) == stored } ?? .all
|
||||
}
|
||||
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.libraryFilter) }
|
||||
}
|
||||
|
||||
/// The library's offer of posters and synopses was turned down.
|
||||
var metadataOfferDismissed: Bool {
|
||||
get { bool(Key.metadataOfferDismissed, default: false) }
|
||||
nonmutating set { defaults.set(newValue, forKey: Key.metadataOfferDismissed) }
|
||||
}
|
||||
|
||||
/// Whether the library folds each franchise into one tile.
|
||||
var libraryGrouped: Bool {
|
||||
get { bool(Key.libraryGrouped, default: true) }
|
||||
nonmutating set { defaults.set(newValue, forKey: Key.libraryGrouped) }
|
||||
}
|
||||
|
||||
/// Whether a background crawl may say that a show gained episodes.
|
||||
var newEpisodeNotifications: Bool {
|
||||
get { bool(Key.newEpisodeNotifications, default: true) }
|
||||
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
|
||||
}
|
||||
|
||||
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
|
||||
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
|
||||
|
||||
private func bool(_ key: String, default value: Bool) -> Bool {
|
||||
defaults.object(forKey: key) as? Bool ?? value
|
||||
}
|
||||
@@ -51,5 +90,10 @@ struct SettingsStore {
|
||||
static let backgroundAudio = "background_audio"
|
||||
static let hardwareDecoding = "hardware_decoding"
|
||||
static let cacheBudgetGib = "cache_budget_gib"
|
||||
static let librarySort = "library_sort"
|
||||
static let libraryFilter = "library_filter"
|
||||
static let libraryGrouped = "library_grouped"
|
||||
static let metadataOfferDismissed = "metadata_offer_dismissed"
|
||||
static let newEpisodeNotifications = "new_episode_notifications"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ final class DownloadCoordinator {
|
||||
/// Every retained download, sorted by label. Re-read after each write.
|
||||
private(set) var records: [RetainedDownload] = []
|
||||
|
||||
/// Why a queued download is not running, when it is not merely a free
|
||||
/// slot: a Wi-Fi-only download on mobile data waits without a word, and
|
||||
/// "Queued" for an hour on the bus reads as stuck.
|
||||
private(set) var queueReason: String?
|
||||
|
||||
/// A download finished or was removed: the catalog has changed.
|
||||
@ObservationIgnored var onFinished: (() -> Void)?
|
||||
|
||||
@@ -125,6 +130,10 @@ final class DownloadCoordinator {
|
||||
/// Re-apply "Wi-Fi only" to what is queued and running: a season queued
|
||||
/// before the setting was turned on must not then download over cellular.
|
||||
func applyNetworkPolicy() {
|
||||
queueReason = switch path?.status {
|
||||
case .satisfied?: allowed ? nil : "Waiting for Wi-Fi"
|
||||
default: "Waiting for a connection"
|
||||
}
|
||||
if allowed {
|
||||
resumeQueued()
|
||||
} else {
|
||||
|
||||