Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0be9e3c8 | ||
|
|
4a3a7291b8 | ||
|
|
ab7f057afe | ||
|
|
b1ea076ea3 | ||
|
|
8d615579e2 | ||
|
|
cafd969567 | ||
|
|
69981e3b45 | ||
|
|
cbe0f7166b | ||
|
|
f0402e74f9 | ||
|
|
d4e9628199 | ||
|
|
4b9cbd26bc | ||
|
|
588a7fcb09 | ||
|
|
e3e855a02d | ||
|
|
7790ae9cbb | ||
|
|
f0fe90f0fe | ||
|
|
51454b5a6e | ||
|
|
c44b04cf4a | ||
|
|
85bc975f5f | ||
|
|
4f130d1d6e | ||
|
|
fb3a97b408 | ||
|
|
04fc1f04fe | ||
|
|
aa0f4b1250 | ||
|
|
c261f88c3f | ||
|
|
bf3fa19c70 | ||
|
|
01eee78317 | ||
|
|
2546bab0a6 | ||
|
|
8d795c29e6 | ||
|
|
47c482498b | ||
|
|
31bdd75559 | ||
|
|
d43da778b6 | ||
|
|
abb3e3aaf8 | ||
|
|
5b931111b3 | ||
|
|
5c0519a226 | ||
|
|
5c15f45ace | ||
|
|
eb67df0aa0 | ||
|
|
fdf6b0359b | ||
|
|
6ddbcea429 | ||
|
|
a79e41aa8a | ||
|
|
89e55aac12 | ||
|
|
0f96cc4514 | ||
|
|
9d72acce88 | ||
|
|
d154002109 | ||
|
|
690455f7dd | ||
|
|
01c252b794 | ||
|
|
6d1943f2bd | ||
|
|
5dfb717235 | ||
|
|
3ff55a9af9 | ||
|
|
33da1563ee | ||
|
|
7994097a25 | ||
|
|
77a926cbf5 | ||
|
|
603cc0c1a9 | ||
|
|
5807493a4a | ||
|
|
a306d02cf1 | ||
|
|
c6b0457c4d | ||
|
|
c5490ab3da | ||
|
|
9e53a9bbdb | ||
|
|
ce02bbc35a | ||
|
|
0b9a9dab50 | ||
|
|
73973841c2 | ||
|
|
2f3be1d193 | ||
|
|
8e42906a7f | ||
|
|
dc70cafbb7 | ||
|
|
a28135cd51 | ||
|
|
4b9c01d52d | ||
|
|
3356602951 | ||
|
|
537217e70a | ||
|
|
ed05a1e723 | ||
|
|
da16ab0575 | ||
|
|
d9ca8f8d60 | ||
|
|
26cccd5c63 | ||
|
|
0b2813cd42 | ||
|
|
6cdab2fde1 | ||
|
|
6239780978 | ||
|
|
67cf3044f1 | ||
|
|
66be35c3d4 | ||
|
|
046cfe62b8 | ||
|
|
e3dcbec1f2 | ||
|
|
99abc8f205 | ||
|
|
3638e0e5df | ||
|
|
aef182e7e8 | ||
|
|
6853725ca0 | ||
|
|
4c80076762 | ||
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 |
No files matched your search
@@ -39,8 +39,8 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
ANDROID_API: '36'
|
ANDROID_API: '36'
|
||||||
ANDROID_BUILD_TOOLS: '37.0.0'
|
ANDROID_BUILD_TOOLS: '37.0.0'
|
||||||
ANDROID_NDK: '29.0.14206865'
|
ANDROID_NDK: '30.0.16248370'
|
||||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -107,19 +107,34 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
# One cache for both jobs: the signed build compiles the same crates for
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
# the same targets. Saved on failure too, so a run that breaks late does
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
# not throw away everything it compiled before the break.
|
# `release` tree under it before saving, so with its target caching on
|
||||||
|
# every run rebuilt both ABIs from scratch.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
with:
|
with:
|
||||||
shared-key: android
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# The target directory as is: the host build the UniFFI bindings come
|
||||||
|
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||||
|
# build compiles the same crates for the same targets. A new lockfile
|
||||||
|
# starts from the last one's tree, so a version bump rebuilds only the
|
||||||
|
# workspace crates.
|
||||||
|
- id: target
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
# libmpv and its dependency tree are the slowest part of the build, and
|
# libmpv and its dependency tree are the slowest part of the build, and
|
||||||
# depend only on the pinned revision and the build script, which stamps
|
# depend only on the pinned revision and the build script, which stamps
|
||||||
# its output with both and skips the build when the stamp matches. Built
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
@@ -167,6 +182,12 @@ jobs:
|
|||||||
- name: Android checks and artifacts
|
- name: Android checks and artifacts
|
||||||
run: bash scripts/build-android.sh all
|
run: bash scripts/build-android.sh all
|
||||||
|
|
||||||
|
- if: steps.target.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||||
|
|
||||||
signed-release:
|
signed-release:
|
||||||
name: Signed release
|
name: Signed release
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||||
@@ -209,19 +230,34 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
# One cache for both jobs: the signed build compiles the same crates for
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
# the same targets. Saved on failure too, so a run that breaks late does
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
# not throw away everything it compiled before the break.
|
# `release` tree under it before saving, so with its target caching on
|
||||||
|
# every run rebuilt both ABIs from scratch.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
with:
|
with:
|
||||||
shared-key: android
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# The target directory as is: the host build the UniFFI bindings come
|
||||||
|
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||||
|
# build compiles the same crates for the same targets. A new lockfile
|
||||||
|
# starts from the last one's tree, so a version bump rebuilds only the
|
||||||
|
# workspace crates.
|
||||||
|
- id: target
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
# libmpv and its dependency tree are the slowest part of the build, and
|
# libmpv and its dependency tree are the slowest part of the build, and
|
||||||
# depend only on the pinned revision and the build script, which stamps
|
# depend only on the pinned revision and the build script, which stamps
|
||||||
# its output with both and skips the build when the stamp matches. Built
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
@@ -266,6 +302,12 @@ jobs:
|
|||||||
- name: Build signed APK and AAB
|
- name: Build signed APK and AAB
|
||||||
run: bash scripts/build-android.sh signed
|
run: bash scripts/build-android.sh signed
|
||||||
|
|
||||||
|
- if: steps.target.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||||
|
|
||||||
# The APK bundles a GPL libmpv, so its corresponding source ships next to
|
# The APK bundles a GPL libmpv, so its corresponding source ships next to
|
||||||
# it. The AAB is for Play only and stays out of the GitHub release.
|
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||||
- name: Stage release files
|
- name: Stage release files
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ jobs:
|
|||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
|
||||||
# libmpv for pstr-player.
|
# libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
|
||||||
- name: Install libmpv
|
- name: Install libmpv and WebKitGTK
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libmpv-dev
|
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
|
||||||
|
|
||||||
- run: cargo fmt --all -- --check
|
- run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: iOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'ios/**'
|
||||||
|
- 'android/app/src/main/res/font/**'
|
||||||
|
- 'android/app/src/main/assets/licenses/**'
|
||||||
|
- 'crates/pstr-android/**'
|
||||||
|
- 'crates/pstr-core/**'
|
||||||
|
- 'crates/pstr-meta/**'
|
||||||
|
- 'crates/pstr-stream/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'scripts/build-ios.sh'
|
||||||
|
- '.github/workflows/ios.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Unit tests and unsigned .ipa
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||||
|
|
||||||
|
- name: Restore the Rust target tree
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
- name: Install XcodeGen
|
||||||
|
run: brew install xcodegen
|
||||||
|
|
||||||
|
- name: Bindings
|
||||||
|
run: scripts/build-ios.sh bindings
|
||||||
|
|
||||||
|
- name: Unit tests on the simulator
|
||||||
|
run: scripts/build-ios.sh test
|
||||||
|
|
||||||
|
# SideStore and AltStore re-sign whatever they install with the viewer's
|
||||||
|
# own Apple ID, so the release artifact is unsigned.
|
||||||
|
- name: Unsigned .ipa
|
||||||
|
run: |
|
||||||
|
scripts/build-ios.sh rust
|
||||||
|
scripts/build-ios.sh ipa
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: dist/*.ipa
|
||||||
|
if-no-files-found: error
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libmpv-dev rpm
|
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
|
||||||
|
|
||||||
- name: Gate
|
- name: Gate
|
||||||
run: |
|
run: |
|
||||||
@@ -150,10 +150,17 @@ jobs:
|
|||||||
signed_release: true
|
signed_release: true
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
|
||||||
|
# Apple ID when they install it.
|
||||||
|
ios:
|
||||||
|
name: iOS
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/ios.yml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows, android]
|
needs: [linux, windows, android, ios]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -172,6 +179,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: android
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: ios
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.1"
|
version = "1.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.7.2" }
|
proton-sdk = { version = "0.9.0" }
|
||||||
proton-drive-rs = { version = "0.7.2" }
|
proton-drive-rs = { version = "0.9.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -54,6 +54,8 @@ directories = "5"
|
|||||||
# Local dates for the history page. Already in the tree through the SDK.
|
# Local dates for the history page. Already in the tree through the SDK.
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
# The id a watch-history file is named by. Already in the tree through the SDK.
|
||||||
|
getrandom = "0.3"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
# The share URL fragment and the custom password are both secrets; they go in
|
# The share URL fragment and the custom password are both secrets; they go in
|
||||||
@@ -71,8 +73,10 @@ keyring = { version = "3" }
|
|||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
||||||
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
||||||
# eframe is drawing into.
|
# eframe is drawing into. `accesskit` hands the widget tree to the platform's
|
||||||
|
# screen reader (AT-SPI, UI Automation, NSAccessibility).
|
||||||
eframe = { version = "0.35", default-features = false, features = [
|
eframe = { version = "0.35", default-features = false, features = [
|
||||||
|
"accesskit",
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
"glow",
|
"glow",
|
||||||
"wayland",
|
"wayland",
|
||||||
@@ -103,6 +107,14 @@ dbus = "0.9"
|
|||||||
windows-sys = "0.61"
|
windows-sys = "0.61"
|
||||||
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
tauri-winrt-notification = "0.8"
|
tauri-winrt-notification = "0.8"
|
||||||
|
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
|
||||||
|
embed-resource = "3"
|
||||||
|
# Proton's CAPTCHA page, which reports success by posting a message to its
|
||||||
|
# host, so it needs a webview this app controls. Run in a child process of its
|
||||||
|
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
|
||||||
|
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
|
||||||
|
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
|
||||||
|
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.18"
|
lru = "0.18"
|
||||||
|
|||||||
@@ -10,16 +10,38 @@ gained about twenty features on 2026-10-02 that Android does not have.
|
|||||||
|
|
||||||
## Status (2026-10-03)
|
## Status (2026-10-03)
|
||||||
|
|
||||||
Done: section 0, every parity item, the Library, Title, Shares and Settings
|
Done: section 0, every parity item, and every recommendation in section 2 —
|
||||||
redesigns, Inter, and screenshot tests. Not yet verified on a device; the
|
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
|
||||||
host gate (ktlint, lint, unit and screenshot tests) and CI's build job pass.
|
bars drawn edge to edge, search behind an icon, skeleton loading and
|
||||||
|
generated placeholder art, Settings sub-pages and a language picker, a
|
||||||
|
long-press Change match, Inter, light and tablet screenshots, the library
|
||||||
|
and a title side by side from 840 dp, a poster-to-backdrop shared element
|
||||||
|
transition, and predictive back on the title page.
|
||||||
|
|
||||||
Open, from section 2: per-screen app bars and edge-to-edge under the status
|
Two departures from the text below. The tablet layout is a two-pane `Row`
|
||||||
bar, skeleton loading and generated placeholder art, a search icon that
|
rather than `ListDetailPaneScaffold`, which would have added the
|
||||||
expands, a language picker, Settings sub-pages, the Downloads page, light
|
`adaptive-layout` artifact for one split. Pull to refresh shows the
|
||||||
theme and tablet screenshots, `ListDetailPaneScaffold` on tablets, shared
|
indicator while a crawl runs, but not the crawl's progress.
|
||||||
element transitions and predictive back, and a long-press Change match (the
|
|
||||||
title page has it).
|
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)
|
## 0. CI (done)
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ layer and the player are done and verified against a real share, and the app has
|
|||||||
a library wall, a page per title with seasons and episodes,
|
a library wall, a page per title with seasons and episodes,
|
||||||
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
||||||
button for openings and endings, next/previous episode with an "up next"
|
button for openings and endings, next/previous episode with an "up next"
|
||||||
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
|
countdown, subtitle files beside the video, optional metadata, themes and share
|
||||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
management. What is left is mostly running the macOS packaging on a real Mac. See
|
||||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
`docs/DEVELOPMENT.md`.
|
||||||
|
|
||||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
||||||
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
||||||
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||||
|
|
||||||
|
An iOS 17+ client under `ios/` is a SwiftUI port of the Android one over the same
|
||||||
|
Rust engine, built as an unsigned `.ipa` for SideStore or AltStore; see
|
||||||
|
[`docs/IOS.md`](docs/IOS.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
@@ -72,8 +76,8 @@ against the SDK's own range download.
|
|||||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
||||||
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback, and an iOS 17+ port of it for sideloading. |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -83,7 +87,9 @@ Rust 2024, MSRV 1.96.
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those
|
`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
|
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
|
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||||
crates.io; nothing here builds against a sibling checkout.
|
crates.io; nothing here builds against a sibling checkout.
|
||||||
@@ -97,6 +103,9 @@ Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
|||||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||||
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
||||||
|
|
||||||
|
iOS builds need macOS with Xcode, XcodeGen and the `aarch64-apple-ios` Rust
|
||||||
|
target: `scripts/build-ios.sh all` writes `dist/proton-stream-<version>-ios.ipa`.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
- The share URL fragment **is** the decryption password. It and any custom
|
- The share URL fragment **is** the decryption password. It and any custom
|
||||||
@@ -119,9 +128,10 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
The shared Rust crates and desktop application are MIT licensed. The Android and
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
iOS applications and their combined native distributions are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and
|
||||||
|
the bundled-component notices.
|
||||||
The desktop application embeds the Inter typeface, under the SIL Open Font
|
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
and the Phosphor icon font, under MIT.
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -7,7 +7,7 @@ distributions.
|
|||||||
| Component | License |
|
| Component | License |
|
||||||
|---|---|
|
|---|---|
|
||||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
|
||||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||||
| Kotlin | Apache-2.0 |
|
| Kotlin | Apache-2.0 |
|
||||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ android {
|
|||||||
namespace = "io.narl.protonstream"
|
namespace = "io.narl.protonstream"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
buildToolsVersion = "37.0.0"
|
buildToolsVersion = "37.0.0"
|
||||||
ndkVersion = "29.0.14206865"
|
ndkVersion = "30.0.16248370"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "io.narl.protonstream"
|
applicationId = "io.narl.protonstream"
|
||||||
@@ -180,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
|||||||
val hostRustLibrary = repositoryRoot.resolve(
|
val hostRustLibrary = repositoryRoot.resolve(
|
||||||
"target/debug/${System.mapLibraryName("pstr_android")}",
|
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||||
)
|
)
|
||||||
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
|
||||||
|
|
||||||
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||||
group = "native"
|
group = "native"
|
||||||
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||||
workingDir(repositoryRoot)
|
workingDir(repositoryRoot)
|
||||||
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||||
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
|
||||||
outputs.files(
|
// deletes the declared outputs of a task it has no history for, which on a
|
||||||
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
// fresh CI checkout threw away the tree just restored from the cache and
|
||||||
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
// rebuilt libmpv from source on every run.
|
||||||
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
|
||||||
stagedMpvDir.map { it.file("REVISION") },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val buildRustHost by tasks.registering(Exec::class) {
|
val buildRustHost by tasks.registering(Exec::class) {
|
||||||
|
|||||||
@@ -7,11 +7,21 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Android TV: listed in its launcher, and installable without a touch
|
||||||
|
screen. Neither is required, so phones are offered the same build.
|
||||||
|
-->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ProtonStreamApplication"
|
android:name=".ProtonStreamApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
tools:targetApi="33"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:banner="@drawable/tv_banner"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ProtonStream">
|
android:theme="@style/Theme.ProtonStream">
|
||||||
@@ -33,11 +43,11 @@
|
|||||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:supportsPictureInPicture="true"
|
|
||||||
android:resizeableActivity="true">
|
android:resizeableActivity="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!--
|
<!--
|
||||||
Proton Drive public links. autoVerify="false", said explicitly:
|
Proton Drive public links. autoVerify="false", said explicitly:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
|||||||
| Component | License |
|
| Component | License |
|
||||||
|---|---|
|
|---|---|
|
||||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
|
||||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||||
| Kotlin | Apache-2.0 |
|
| Kotlin | Apache-2.0 |
|
||||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ extern "C" {
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ struct PlaybackState {
|
|||||||
/// and must not be attributed to the file being loaded.
|
/// and must not be attributed to the file being loaded.
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
/// The picture's display size, once mpv has decoded enough to know it.
|
/// The picture's display size, once mpv has decoded enough to know it.
|
||||||
/// Zero until then, and the aspect Picture-in-Picture is given.
|
/// Zero until then.
|
||||||
double video_width = 0.0;
|
double video_width = 0.0;
|
||||||
double video_height = 0.0;
|
double video_height = 0.0;
|
||||||
/// Which loaded file this state describes. Bumped by every load, so a
|
/// Which loaded file this state describes. Bumped by every load, so a
|
||||||
@@ -210,6 +211,17 @@ struct PlaybackState {
|
|||||||
/// episode one — which is the difference between resuming an episode where
|
/// episode one — which is the difference between resuming an episode where
|
||||||
/// the viewer left it and resuming it where the *previous* one ended.
|
/// the viewer left it and resuming it where the *previous* one ended.
|
||||||
int64_t generation = 0;
|
int64_t generation = 0;
|
||||||
|
/// mpv has loaded this generation's file and made its own track pick,
|
||||||
|
/// which is the first moment the pick can be second-guessed.
|
||||||
|
bool loaded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A subtitle file published for mpv, and what to call it.
|
||||||
|
struct Subtitle {
|
||||||
|
uint64_t handle = 0;
|
||||||
|
std::string title;
|
||||||
|
std::string language;
|
||||||
|
bool select = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Player {
|
class Player {
|
||||||
@@ -338,7 +350,10 @@ class Player {
|
|||||||
state_.generation = generation;
|
state_.generation = generation;
|
||||||
state_.loading = true;
|
state_.loading = true;
|
||||||
error_.clear();
|
error_.clear();
|
||||||
|
for (const auto &subtitle : pending_subtitles_) released_.push_back(subtitle.handle);
|
||||||
|
pending_subtitles_.clear();
|
||||||
}
|
}
|
||||||
|
flush_released();
|
||||||
const std::string url = "pstr://" + std::to_string(handle);
|
const std::string url = "pstr://" + std::to_string(handle);
|
||||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||||
if (mpv_command(mpv_, command) < 0) {
|
if (mpv_command(mpv_, command) < 0) {
|
||||||
@@ -372,6 +387,27 @@ class Player {
|
|||||||
mpv_command(mpv_, command);
|
mpv_command(mpv_, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A subtitle file beside the file of load `generation`, added once mpv
|
||||||
|
/// has that file loaded: an external track belongs to the file open at
|
||||||
|
/// the time. Handed over for a file already replaced, it is released
|
||||||
|
/// instead. `select` turns it on, but only when the file has no subtitle
|
||||||
|
/// of its own playing.
|
||||||
|
void add_subtitle(int64_t generation, Subtitle subtitle) {
|
||||||
|
bool now = false;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
if (generation != state_.generation) {
|
||||||
|
released_.push_back(subtitle.handle);
|
||||||
|
} else if (loaded_generation_ == generation) {
|
||||||
|
now = true;
|
||||||
|
} else {
|
||||||
|
pending_subtitles_.push_back(std::move(subtitle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush_released();
|
||||||
|
if (now) attach_subtitle(subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
PlaybackState state() const {
|
PlaybackState state() const {
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
return state_;
|
return state_;
|
||||||
@@ -395,6 +431,8 @@ class Player {
|
|||||||
char *lang_raw = nullptr;
|
char *lang_raw = nullptr;
|
||||||
char *title_raw = nullptr;
|
char *title_raw = nullptr;
|
||||||
int selected = 0;
|
int selected = 0;
|
||||||
|
int is_default = 0;
|
||||||
|
int forced = 0;
|
||||||
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
||||||
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||||
MpvString type(type_raw);
|
MpvString type(type_raw);
|
||||||
@@ -403,10 +441,14 @@ class Player {
|
|||||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||||
MpvString title(title_raw);
|
MpvString title(title_raw);
|
||||||
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
||||||
|
mpv_get_property(mpv_, (base + "default").c_str(), MPV_FORMAT_FLAG, &is_default);
|
||||||
|
mpv_get_property(mpv_, (base + "forced").c_str(), MPV_FORMAT_FLAG, &forced);
|
||||||
if (i) json += ',';
|
if (i) json += ',';
|
||||||
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||||
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
||||||
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}";
|
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") +
|
||||||
|
",\"default\":" + (is_default ? "true" : "false") +
|
||||||
|
",\"forced\":" + (forced ? "true" : "false") + "}";
|
||||||
}
|
}
|
||||||
return json + ']';
|
return json + ']';
|
||||||
}
|
}
|
||||||
@@ -434,6 +476,35 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void attach_subtitle(const Subtitle &subtitle) {
|
||||||
|
bool showing = false;
|
||||||
|
if (char *sid = mpv_get_property_string(mpv_, "sid")) {
|
||||||
|
showing = std::strcmp(sid, "no") != 0;
|
||||||
|
mpv_free(sid);
|
||||||
|
}
|
||||||
|
const std::string url = "pstr://" + std::to_string(subtitle.handle);
|
||||||
|
std::vector<const char *> command{
|
||||||
|
"sub-add", url.c_str(), subtitle.select && !showing ? "select" : "auto",
|
||||||
|
subtitle.title.c_str()};
|
||||||
|
if (!subtitle.language.empty()) command.push_back(subtitle.language.c_str());
|
||||||
|
command.push_back(nullptr);
|
||||||
|
// On failure mpv may never have opened it, so nothing will close it.
|
||||||
|
// Releasing one it did open and close is a no-op.
|
||||||
|
if (mpv_command(mpv_, command.data()) < 0) pstr_android_stream_release(subtitle.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the subtitle streams queued under `state_mutex_`, outside it:
|
||||||
|
/// a release runs Rust destructors, which must not run under a lock a
|
||||||
|
/// reader of the state may be waiting on.
|
||||||
|
void flush_released() {
|
||||||
|
std::vector<uint64_t> handles;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
handles.swap(released_);
|
||||||
|
}
|
||||||
|
for (const uint64_t handle : handles) pstr_android_stream_release(handle);
|
||||||
|
}
|
||||||
|
|
||||||
struct MpvFree {
|
struct MpvFree {
|
||||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||||
};
|
};
|
||||||
@@ -534,6 +605,17 @@ class Player {
|
|||||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||||
const double start = pending_start_.exchange(0.0);
|
const double start = pending_start_.exchange(0.0);
|
||||||
if (start > 0.0) set_double("time-pos", start);
|
if (start > 0.0) set_double("time-pos", start);
|
||||||
|
std::vector<Subtitle> ready;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(state_mutex_);
|
||||||
|
// Mid-load, this is the outgoing file finishing its load.
|
||||||
|
if (!state_.loading) {
|
||||||
|
loaded_generation_ = state_.generation;
|
||||||
|
state_.loaded = true;
|
||||||
|
ready.swap(pending_subtitles_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const auto &subtitle : ready) attach_subtitle(subtitle);
|
||||||
}
|
}
|
||||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||||
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||||
@@ -757,6 +839,12 @@ class Player {
|
|||||||
bool surface_changed_ = false;
|
bool surface_changed_ = false;
|
||||||
std::atomic<bool> frame_ready_{false};
|
std::atomic<bool> frame_ready_{false};
|
||||||
std::atomic<double> pending_start_{0.0};
|
std::atomic<double> pending_start_{0.0};
|
||||||
|
/// The load generation whose file mpv has loaded, under `state_mutex_`.
|
||||||
|
int64_t loaded_generation_ = -1;
|
||||||
|
/// Subtitles handed over before their file loaded, under `state_mutex_`.
|
||||||
|
std::vector<Subtitle> pending_subtitles_;
|
||||||
|
/// Streams to release once `state_mutex_` is let go.
|
||||||
|
std::vector<uint64_t> released_;
|
||||||
};
|
};
|
||||||
|
|
||||||
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
||||||
@@ -850,6 +938,17 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
|||||||
: JNI_FALSE);
|
: JNI_FALSE);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAddSubtitle(JNIEnv *env, jobject, jlong handle, jlong generation, jlong stream, jstring title, jstring language, jboolean select) noexcept {
|
||||||
|
c_boundary([=] {
|
||||||
|
Subtitle subtitle{static_cast<uint64_t>(stream), {}, {}, select == JNI_TRUE};
|
||||||
|
auto *p = from(handle);
|
||||||
|
if (!p || !utf8(env, title, subtitle.title) || !utf8(env, language, subtitle.language)) {
|
||||||
|
pstr_android_stream_release(subtitle.handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p->add_subtitle(generation, std::move(subtitle));
|
||||||
|
});
|
||||||
|
}
|
||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||||
}
|
}
|
||||||
@@ -881,8 +980,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
|||||||
state.video_width, state.video_height,
|
state.video_width, state.video_height,
|
||||||
static_cast<jdouble>(state.end_reason),
|
static_cast<jdouble>(state.end_reason),
|
||||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||||
state.seeking ? 1.0 : 0.0, state.cached_until};
|
state.seeking ? 1.0 : 0.0, state.cached_until,
|
||||||
constexpr jsize kFields = 14;
|
state.loaded ? 1.0 : 0.0};
|
||||||
|
constexpr jsize kFields = 15;
|
||||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||||
|
|||||||
@@ -1,53 +1,38 @@
|
|||||||
package io.narl.protonstream
|
package io.narl.protonstream
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.PictureInPictureParams
|
|
||||||
import android.app.RemoteAction
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Rational
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.playback.MpvPlaybackState
|
|
||||||
import io.narl.protonstream.playback.NativeMpvHost
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlaybackService
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||||
|
import io.narl.protonstream.sync.WatchSyncWorker
|
||||||
import io.narl.protonstream.ui.ProtonStreamApp
|
import io.narl.protonstream.ui.ProtonStreamApp
|
||||||
import io.narl.protonstream.ui.theme.AppearanceState
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import uniffi.pstr_android.PaletteRecord
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
private val inPictureInPicture = mutableStateOf(false)
|
|
||||||
|
|
||||||
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||||
private val incomingShareLink = mutableStateOf<String?>(null)
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||||
@@ -68,30 +53,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The transport behind the buttons Android draws over the PiP window.
|
|
||||||
*
|
|
||||||
* A broadcast rather than a service intent: PiP is one of the states in
|
|
||||||
* which a background service start is refused, and the receiver is
|
|
||||||
* registered for as long as the activity that owns the window exists.
|
|
||||||
*/
|
|
||||||
private val pictureInPictureControls = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
val host = playerHost.value ?: return
|
|
||||||
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
|
||||||
CONTROL_PLAY -> host.setPaused(false)
|
|
||||||
CONTROL_PAUSE -> host.setPaused(true)
|
|
||||||
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
|
||||||
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val notificationPermission = registerForActivityResult(
|
private val notificationPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Before anything draws. The palette decides the window's own
|
// Before anything draws. The palette decides the window's own
|
||||||
@@ -115,34 +80,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
playbackConnection,
|
playbackConnection,
|
||||||
Context.BIND_AUTO_CREATE,
|
Context.BIND_AUTO_CREATE,
|
||||||
)
|
)
|
||||||
ContextCompat.registerReceiver(
|
NewEpisodesWorker.schedule(this)
|
||||||
this,
|
|
||||||
pictureInPictureControls,
|
|
||||||
IntentFilter(ACTION_PIP_CONTROL),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
|
||||||
// building the params inside onUserLeaveHint is too late for the swipe
|
|
||||||
// home that gesture navigation sends, which never calls it. Re-register
|
|
||||||
// whenever the picture's shape or the transport's does.
|
|
||||||
lifecycleScope.launch {
|
|
||||||
snapshotFlow { playerHost.value }
|
|
||||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
|
||||||
.map { state -> PictureInPictureShape.of(state) }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
|
||||||
}
|
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(
|
ProtonStreamApp(
|
||||||
playerHost.value,
|
playerHost.value,
|
||||||
inPictureInPicture.value,
|
|
||||||
shareLink = incomingShareLink.value,
|
shareLink = incomingShareLink.value,
|
||||||
onShareLinkTaken = { incomingShareLink.value = null },
|
onShareLinkTaken = { incomingShareLink.value = null },
|
||||||
)
|
)
|
||||||
@@ -177,27 +125,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
intent.shareLink()?.let { incomingShareLink.value = it }
|
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
|
||||||
super.onUserLeaveHint()
|
|
||||||
// The backstop for launchers that still send this instead of arming
|
|
||||||
// auto-enter. Entering twice is not an error; not entering at all is.
|
|
||||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
|
||||||
runCatching {
|
|
||||||
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
|
||||||
// The window is now a thumbnail with its own controls. Anything the
|
|
||||||
// player would draw over it — the transport, the title bar, the skip
|
|
||||||
// offer — is unreadable at that size and covers the picture.
|
|
||||||
inPictureInPicture.value = isInPictureInPictureMode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
|
||||||
if (bound) {
|
if (bound) {
|
||||||
unbindService(playbackConnection)
|
unbindService(playbackConnection)
|
||||||
bound = false
|
bound = false
|
||||||
@@ -207,89 +135,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
|
// Where the viewer stopped is what their other devices want next, and
|
||||||
|
// a process in the background may not live to the next timed sync.
|
||||||
|
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||||
if (
|
if (
|
||||||
!isChangingConfigurations &&
|
!isChangingConfigurations &&
|
||||||
!isInPictureInPictureMode &&
|
|
||||||
!SettingsStore(this).backgroundAudio
|
!SettingsStore(this).backgroundAudio
|
||||||
) {
|
) {
|
||||||
playerHost.value?.stop()
|
playerHost.value?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
|
||||||
private data class PictureInPictureShape(
|
|
||||||
val active: Boolean,
|
|
||||||
val paused: Boolean,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
|
||||||
active = state != null && state.duration > 0.0 && !state.ended,
|
|
||||||
paused = state?.paused != false,
|
|
||||||
width = state?.videoWidth?.toInt() ?: 0,
|
|
||||||
height = state?.videoHeight?.toInt() ?: 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAspectRatio(aspectRatio(width, height))
|
|
||||||
.setAutoEnterEnabled(active)
|
|
||||||
.setSeamlessResizeEnabled(false)
|
|
||||||
// The player owns the whole window, so the picture's rectangle is
|
|
||||||
// the window's. Without it the transition into PiP cross-fades from
|
|
||||||
// a screenshot instead of shrinking the video that is already there.
|
|
||||||
.setSourceRectHint(windowBounds())
|
|
||||||
.setActions(
|
|
||||||
listOf(
|
|
||||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
|
||||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
|
||||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
|
||||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
|
||||||
|
|
||||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(this, icon),
|
|
||||||
label,
|
|
||||||
label,
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
this,
|
|
||||||
requestCode,
|
|
||||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
|
||||||
const val EXTRA_CONTROL = "control"
|
|
||||||
const val CONTROL_PLAY = "play"
|
|
||||||
const val CONTROL_PAUSE = "pause"
|
|
||||||
const val CONTROL_NEXT = "next"
|
|
||||||
const val CONTROL_PREVIOUS = "previous"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
|
||||||
* and refuses the request outright rather than clamping it — so a
|
|
||||||
* 2.40:1 scope release would take PiP away entirely.
|
|
||||||
*/
|
|
||||||
fun aspectRatio(width: Int, height: Int): Rational {
|
|
||||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
|
||||||
val ratio = width.toDouble() / height
|
|
||||||
return when {
|
|
||||||
ratio > MAX_RATIO -> Rational(239, 100)
|
|
||||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
|
||||||
else -> Rational(width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const val MAX_RATIO = 2.39
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,15 @@ package io.narl.protonstream
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
|
||||||
class ProtonStreamApplication : Application() {
|
class ProtonStreamApplication : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NativeRuntime.initialize(this)
|
NativeRuntime.initialize(this)
|
||||||
|
// SharedPreferences reads its file on first use, and the first use was
|
||||||
|
// on the main thread — in the playback service's start callback among
|
||||||
|
// others. Read here, off it, and every later read is from memory.
|
||||||
|
Thread({ SettingsStore.preload(this) }, "settings-preload").start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ data class RetainedDownload(
|
|||||||
val total: Long = 0,
|
val total: Long = 0,
|
||||||
val status: String = STATUS_QUEUED,
|
val status: String = STATUS_QUEUED,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
/** Recent transfer rate while running; 0 when unknown or not running. */
|
||||||
|
val bytesPerSecond: Long = 0,
|
||||||
) {
|
) {
|
||||||
val key: String get() = "$shareId\u001f$linkId"
|
val key: String get() = "$shareId\u001f$linkId"
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
|
|||||||
.put("total", record.total)
|
.put("total", record.total)
|
||||||
.put("status", record.status)
|
.put("status", record.status)
|
||||||
.put("error", record.error)
|
.put("error", record.error)
|
||||||
|
.put("rate", record.bytesPerSecond)
|
||||||
.toString()
|
.toString()
|
||||||
|
|
||||||
private fun decode(encoded: String): RetainedDownload {
|
private fun decode(encoded: String): RetainedDownload {
|
||||||
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
|
|||||||
total = json.optLong("total"),
|
total = json.optLong("total"),
|
||||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||||
error = json.optString("error").takeIf(String::isNotBlank),
|
error = json.optString("error").takeIf(String::isNotBlank),
|
||||||
|
bytesPerSecond = json.optLong("rate"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package io.narl.protonstream.download
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
@@ -25,20 +29,39 @@ class OfflineDownloadWorker(
|
|||||||
) : CoroutineWorker(appContext, parameters) {
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo(0))
|
|
||||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||||
val store = DownloadStateStore(applicationContext)
|
val store = DownloadStateStore(applicationContext)
|
||||||
|
setForeground(createForegroundInfo(label, null))
|
||||||
|
postSummary(store)
|
||||||
|
try {
|
||||||
|
return transfer(store, shareId, volumeId, linkId, label)
|
||||||
|
} finally {
|
||||||
|
postSummary(store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun transfer(
|
||||||
|
store: DownloadStateStore,
|
||||||
|
shareId: String,
|
||||||
|
volumeId: String,
|
||||||
|
linkId: String,
|
||||||
|
label: String,
|
||||||
|
): Result {
|
||||||
|
// Queued, not running: the engine lets two downloads transfer at once
|
||||||
|
// and holds the rest back, and only the first progress report means
|
||||||
|
// this one has a slot.
|
||||||
var retained = store.get(shareId, linkId)
|
var retained = store.get(shareId, linkId)
|
||||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||||
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
|
||||||
store.put(retained)
|
store.put(retained)
|
||||||
|
|
||||||
val observer = object : DownloadObserver {
|
val observer = object : DownloadObserver {
|
||||||
private var reportedAt = 0L
|
private var reportedAt = 0L
|
||||||
private var reportedPercent = -1
|
private var reportedPercent = -1
|
||||||
|
private val rate = TransferRate()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||||
@@ -51,13 +74,14 @@ class OfflineDownloadWorker(
|
|||||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
val complete = total > 0UL && downloaded >= total
|
val complete = total > 0UL && downloaded >= total
|
||||||
|
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reportedAt = now
|
reportedAt = now
|
||||||
reportedPercent = percent
|
reportedPercent = percent
|
||||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||||
setForegroundAsync(createForegroundInfo(percent))
|
setForegroundAsync(createForegroundInfo(label, percent))
|
||||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||||
// this read-modify-write used to put RUNNING back over the
|
// this read-modify-write used to put RUNNING back over the
|
||||||
// viewer's PAUSED and then report the download as cancelled.
|
// viewer's PAUSED and then report the download as cancelled.
|
||||||
@@ -66,6 +90,7 @@ class OfflineDownloadWorker(
|
|||||||
current.copy(
|
current.copy(
|
||||||
downloaded = downloaded.toLong(),
|
downloaded = downloaded.toLong(),
|
||||||
total = total.toLong(),
|
total = total.toLong(),
|
||||||
|
bytesPerSecond = bytesPerSecond,
|
||||||
status = when (current.status) {
|
status = when (current.status) {
|
||||||
RetainedDownload.STATUS_PAUSED,
|
RetainedDownload.STATUS_PAUSED,
|
||||||
RetainedDownload.STATUS_CANCELLED,
|
RetainedDownload.STATUS_CANCELLED,
|
||||||
@@ -114,12 +139,20 @@ class OfflineDownloadWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
/**
|
||||||
|
* This episode's line, under [GROUP]: a season is fourteen workers, and
|
||||||
|
* fourteen loose notifications saying the same thing named nothing.
|
||||||
|
* [progress] is a percentage, or null while waiting for a download slot.
|
||||||
|
*/
|
||||||
|
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle("Making episode available offline")
|
.setContentTitle(label)
|
||||||
.setProgress(100, progress, progress == 0)
|
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
|
||||||
|
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setGroup(GROUP)
|
||||||
.build()
|
.build()
|
||||||
return ForegroundInfo(
|
return ForegroundInfo(
|
||||||
stableNotificationId(id),
|
stableNotificationId(id),
|
||||||
@@ -128,6 +161,36 @@ class OfflineDownloadWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line the group folds into — "Downloading 12 episodes" — or none
|
||||||
|
* once nothing is left to download. Posted as each worker starts and
|
||||||
|
* ends rather than on progress, which each episode's own line carries.
|
||||||
|
*/
|
||||||
|
private fun postSummary(store: DownloadStateStore) {
|
||||||
|
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
val left = store.records().count {
|
||||||
|
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
|
||||||
|
}
|
||||||
|
if (left == 0) {
|
||||||
|
manager.cancel(SUMMARY_ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!allowed) return
|
||||||
|
manager.notify(
|
||||||
|
SUMMARY_ID,
|
||||||
|
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -140,11 +203,45 @@ class OfflineDownloadWorker(
|
|||||||
const val KEY_TOTAL = "total"
|
const val KEY_TOTAL = "total"
|
||||||
private const val MAX_RETRIES = 3
|
private const val MAX_RETRIES = 3
|
||||||
private const val CHANNEL_ID = "offline-downloads"
|
private const val CHANNEL_ID = "offline-downloads"
|
||||||
|
private const val GROUP = "offline-downloads"
|
||||||
|
|
||||||
|
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
|
||||||
|
internal const val SUMMARY_ID = 1
|
||||||
|
|
||||||
/** Shortest gap between two identical-looking progress reports. */
|
/** Shortest gap between two identical-looking progress reports. */
|
||||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes per second over roughly the last few seconds.
|
||||||
|
*
|
||||||
|
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||||
|
* steps seconds apart; a rate beside it is what tells a slow download from a
|
||||||
|
* stuck one. Smoothed, because one block that took a little longer than the
|
||||||
|
* last would otherwise halve the figure for a moment.
|
||||||
|
*/
|
||||||
|
internal class TransferRate {
|
||||||
|
private var lastBytes = -1L
|
||||||
|
private var lastAt = 0L
|
||||||
|
private var smoothed = 0.0
|
||||||
|
|
||||||
|
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
|
||||||
|
fun sample(bytes: Long, now: Long): Long {
|
||||||
|
if (lastBytes < 0 || bytes < lastBytes) {
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return smoothed.toLong()
|
||||||
|
}
|
||||||
|
val elapsed = now - lastAt
|
||||||
|
if (elapsed <= 0) return smoothed.toLong()
|
||||||
|
val instant = (bytes - lastBytes) * 1000.0 / elapsed
|
||||||
|
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
|
||||||
|
lastBytes = bytes
|
||||||
|
lastAt = now
|
||||||
|
return smoothed.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun stableNotificationId(workId: UUID): Int =
|
internal fun stableNotificationId(workId: UUID): Int =
|
||||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
|
||||||
@@ -19,7 +19,10 @@ import java.util.concurrent.locks.ReentrantLock
|
|||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
import uniffi.pstr_android.ChapterPlan
|
import uniffi.pstr_android.ChapterPlan
|
||||||
import uniffi.pstr_android.ChapterRecord
|
import uniffi.pstr_android.ChapterRecord
|
||||||
|
import uniffi.pstr_android.SubtitleFilesRecord
|
||||||
|
import uniffi.pstr_android.SubtitleTrackRecord
|
||||||
import uniffi.pstr_android.chapterPlan
|
import uniffi.pstr_android.chapterPlan
|
||||||
|
import uniffi.pstr_android.fullSubtitlesInstead
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||||
@@ -85,9 +88,9 @@ data class MpvPlaybackState(
|
|||||||
/**
|
/**
|
||||||
* What is on screen, for the surfaces that are not the player.
|
* What is on screen, for the surfaces that are not the player.
|
||||||
*
|
*
|
||||||
* The media session, its notification and Picture-in-Picture all have to name
|
* The media session and its notification have to name the episode, and
|
||||||
* the episode, and none of them can reach the composition that knows it. The
|
* neither can reach the composition that knows it. The player publishes this
|
||||||
* player publishes this once per episode and they read it.
|
* once per episode and they read it.
|
||||||
*/
|
*/
|
||||||
data class NowPlaying(
|
data class NowPlaying(
|
||||||
val media: String,
|
val media: String,
|
||||||
@@ -107,6 +110,10 @@ data class MpvTrack(
|
|||||||
val language: String,
|
val language: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
|
/** The container marks it as the default choice. */
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
/** The container marks it as forced: on-screen text only, by convention. */
|
||||||
|
val forced: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||||
@@ -128,14 +135,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
@Volatile private var currentMedia: String? = null
|
@Volatile private var currentMedia: String? = null
|
||||||
@Volatile private var currentGeneration: Double = -1.0
|
@Volatile private var currentGeneration: Double = -1.0
|
||||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||||
|
/** The load generation whose subtitle pick has been looked at. */
|
||||||
|
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||||
var onPlaybackStarted: (() -> Unit)? = null
|
var onPlaybackStarted: (() -> Unit)? = null
|
||||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||||
var onExplicitStop: (() -> Unit)? = null
|
var onExplicitStop: (() -> Unit)? = null
|
||||||
/**
|
/**
|
||||||
* Where a transport outside the player sends previous/next.
|
* Where a transport outside the player sends previous/next.
|
||||||
*
|
*
|
||||||
* The player owns which episode is open, so the notification, the lock
|
* The player owns which episode is open, so the notification and the lock
|
||||||
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
|
* screen cannot walk the playlist themselves. Both
|
||||||
* run on the main thread, which is where the player's state lives.
|
* run on the main thread, which is where the player's state lives.
|
||||||
*/
|
*/
|
||||||
var onSkipPrevious: (() -> Unit)? = null
|
var onSkipPrevious: (() -> Unit)? = null
|
||||||
@@ -245,7 +254,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
*/
|
*/
|
||||||
fun beginTransition() { mutableAdvancing.value = true }
|
fun beginTransition() { mutableAdvancing.value = true }
|
||||||
|
|
||||||
/** Names the open episode for the media session, its notification and PiP. */
|
/** Names the open episode for the media session and its notification. */
|
||||||
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||||
|
|
||||||
fun setPaused(paused: Boolean) {
|
fun setPaused(paused: Boolean) {
|
||||||
@@ -292,6 +301,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
|
||||||
|
*
|
||||||
|
* Native code holds them until mpv has the file loaded, since an external
|
||||||
|
* track belongs to the file open at the time, and releases them instead
|
||||||
|
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
|
||||||
|
* is turned on only when the file has no subtitle of its own playing;
|
||||||
|
* pass [subtitles] false to turn none on.
|
||||||
|
*/
|
||||||
|
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
|
||||||
|
val generation = currentGeneration.toLong()
|
||||||
|
val added = withOpenHandle { handle ->
|
||||||
|
files.files.forEachIndexed { index, file ->
|
||||||
|
if (currentMedia == media) {
|
||||||
|
val select = subtitles && files.preferred == index.toUInt()
|
||||||
|
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
|
||||||
|
} else {
|
||||||
|
pstrAndroidStreamRelease(file.handle.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
if (poller?.isActive == true) return
|
if (poller?.isActive == true) return
|
||||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||||
@@ -322,6 +355,12 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
// both of which are the main thread's.
|
// both of which are the main thread's.
|
||||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||||
refreshChapters(next)
|
refreshChapters(next)
|
||||||
|
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
|
||||||
|
subtitlesCheckedFor != currentGeneration
|
||||||
|
) {
|
||||||
|
subtitlesCheckedFor = currentGeneration
|
||||||
|
preferFullSubtitles()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||||
if (tick++ % 4 == 0) refreshTracks()
|
if (tick++ % 4 == 0) refreshTracks()
|
||||||
@@ -359,13 +398,32 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the dialogue rather than the signs and songs mpv picked by language
|
||||||
|
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
|
||||||
|
* have made, not the viewer's.
|
||||||
|
*/
|
||||||
|
private fun preferFullSubtitles() {
|
||||||
|
refreshTracks()
|
||||||
|
val subtitles = mutableTracks.value.filter { it.type == "sub" }
|
||||||
|
val id = fullSubtitlesInstead(
|
||||||
|
subtitles.map {
|
||||||
|
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
|
||||||
|
},
|
||||||
|
) ?: return
|
||||||
|
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshTracks() {
|
private fun refreshTracks() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||||
val values = JSONArray(encoded)
|
val values = JSONArray(encoded)
|
||||||
List(values.length()) { index ->
|
List(values.length()) { index ->
|
||||||
values.getJSONObject(index).run {
|
values.getJSONObject(index).run {
|
||||||
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
MpvTrack(
|
||||||
|
getLong("id"), getString("type"), getString("language"), getString("title"),
|
||||||
|
getBoolean("selected"), optBoolean("default"), optBoolean("forced"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onSuccess { mutableTracks.value = it }
|
}.onSuccess { mutableTracks.value = it }
|
||||||
@@ -414,6 +472,14 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
hardwareDecoding: Boolean,
|
hardwareDecoding: Boolean,
|
||||||
): Boolean
|
): Boolean
|
||||||
|
private external fun nativeAddSubtitle(
|
||||||
|
handle: Long,
|
||||||
|
generation: Long,
|
||||||
|
stream: Long,
|
||||||
|
title: String,
|
||||||
|
language: String?,
|
||||||
|
select: Boolean,
|
||||||
|
)
|
||||||
private external fun nativePause(handle: Long, paused: Boolean)
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
private external fun nativeSeek(handle: Long, position: Double)
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
private external fun nativeVolume(handle: Long, volume: Double)
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
@@ -434,7 +500,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||||
private const val FIELDS = 14
|
private const val FIELDS = 15
|
||||||
private const val GENERATION = 6
|
private const val GENERATION = 6
|
||||||
private const val WIDTH = 7
|
private const val WIDTH = 7
|
||||||
private const val HEIGHT = 8
|
private const val HEIGHT = 8
|
||||||
@@ -443,6 +509,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private const val CACHE_PERCENT = 11
|
private const val CACHE_PERCENT = 11
|
||||||
private const val SEEKING = 12
|
private const val SEEKING = 12
|
||||||
private const val CACHED_UNTIL = 13
|
private const val CACHED_UNTIL = 13
|
||||||
|
private const val LOADED = 14
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
|
|||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.narl.protonstream.MainActivity
|
import io.narl.protonstream.MainActivity
|
||||||
import io.narl.protonstream.R
|
import io.narl.protonstream.R
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import io.narl.protonstream.ui.loadArtwork
|
import io.narl.protonstream.ui.loadArtwork
|
||||||
import io.narl.protonstream.ui.loadThumbnail
|
import io.narl.protonstream.ui.loadThumbnail
|
||||||
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||||
class PlaybackService : Service() {
|
class PlaybackService : Service() {
|
||||||
inner class PlaybackBinder : Binder() {
|
inner class PlaybackBinder : Binder() {
|
||||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
|||||||
private var notified: NotificationShape? = null
|
private var notified: NotificationShape? = null
|
||||||
/** The pending teardown, held so an episode transition can call it off. */
|
/** The pending teardown, held so an episode transition can call it off. */
|
||||||
private var retire: Job? = null
|
private var retire: Job? = null
|
||||||
|
/** The open episode's latest reading, which [track] saves. */
|
||||||
|
private var lastReading: MpvPlaybackState? = null
|
||||||
|
private var lastSave = 0L
|
||||||
/** The episode's artwork, and the source key it was fetched for. */
|
/** The episode's artwork, and the source key it was fetched for. */
|
||||||
private var artwork: Pair<String, Bitmap>? = null
|
private var artwork: Pair<String, Bitmap>? = null
|
||||||
private val binder = PlaybackBinder()
|
private val binder = PlaybackBinder()
|
||||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
mediaSession.isActive = true
|
mediaSession.isActive = true
|
||||||
}
|
}
|
||||||
onStateChanged = { updateSession(it) }
|
onStateChanged = { updateSession(it); track(it) }
|
||||||
onExplicitStop = { retirePlayback() }
|
onExplicitStop = { retirePlayback() }
|
||||||
}
|
}
|
||||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swiped away from Recents. With background audio on, this service would
|
||||||
|
* otherwise play on with no screen to return to and no autoplay, so it
|
||||||
|
* saves where the episode got to and stops.
|
||||||
|
*/
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
lastReading?.let { reading ->
|
||||||
|
// Bounded and on this thread: the process may go as soon as the
|
||||||
|
// service stops, and a save launched into its scope would go with it.
|
||||||
|
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||||
|
}
|
||||||
|
host?.stop()
|
||||||
|
retirePlayback()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
runCatching { unregisterReceiver(becomingNoisy) }
|
runCatching { unregisterReceiver(becomingNoisy) }
|
||||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
|||||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the resume position from here rather than from the player screen.
|
||||||
|
*
|
||||||
|
* This service outlives the activity whenever background audio is on, and a
|
||||||
|
* position the screen alone kept stopped being saved the moment the screen
|
||||||
|
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||||
|
* change saves the outgoing episode's last reading before the new one's.
|
||||||
|
*/
|
||||||
|
private fun track(state: MpvPlaybackState) {
|
||||||
|
val media = state.media ?: return
|
||||||
|
if (state.duration <= 0.0) return
|
||||||
|
val previous = lastReading
|
||||||
|
lastReading = state
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val due = when {
|
||||||
|
previous != null && previous.media != media -> {
|
||||||
|
scope.launch(Dispatchers.IO) { save(previous) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
state.ended -> previous?.ended != true
|
||||||
|
state.paused -> previous?.paused != true
|
||||||
|
else -> now - lastSave >= SAVE_EVERY_MS
|
||||||
|
}
|
||||||
|
if (due) {
|
||||||
|
lastSave = now
|
||||||
|
scope.launch(Dispatchers.IO) { save(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun save(state: MpvPlaybackState) {
|
||||||
|
val media = state.media ?: return
|
||||||
|
// Played to the end, or near enough that what is left is credits. A
|
||||||
|
// failure or a stop also "ends", and recording either as watched would
|
||||||
|
// retire an episode nobody saw.
|
||||||
|
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||||
|
runCatching {
|
||||||
|
NativeRuntime.engine().saveWatchState(
|
||||||
|
media.substringBeforeLast(':'),
|
||||||
|
media.substringAfterLast(':'),
|
||||||
|
state.position.coerceIn(0.0, state.duration),
|
||||||
|
state.duration,
|
||||||
|
watched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateSession(state: MpvPlaybackState) {
|
private fun updateSession(state: MpvPlaybackState) {
|
||||||
refreshSession(state, host?.nowPlaying?.value)
|
refreshSession(state, host?.nowPlaying?.value)
|
||||||
if (state.ended) scheduleRetire() else cancelRetire()
|
if (state.ended) scheduleRetire() else cancelRetire()
|
||||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
|||||||
private const val TRANSITION_GRACE_MS = 30_000L
|
private const val TRANSITION_GRACE_MS = 30_000L
|
||||||
/** How far the volume drops for a navigation prompt or a notification. */
|
/** How far the volume drops for a navigation prompt or a notification. */
|
||||||
private const val DUCK_FACTOR = 0.3
|
private const val DUCK_FACTOR = 0.3
|
||||||
|
private const val SAVE_EVERY_MS = 5_000L
|
||||||
|
/** How long a swipe from Recents may hold the main thread to save. */
|
||||||
|
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||||
|
|
||||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
|
|||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.accessibility.AccessibilityManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@@ -12,13 +13,13 @@ import androidx.compose.animation.fadeOut
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.Speed
|
import androidx.compose.material.icons.filled.Speed
|
||||||
import androidx.compose.material.icons.filled.Forward30
|
import androidx.compose.material.icons.filled.Forward30
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
import androidx.compose.material.icons.filled.Fullscreen
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.FullscreenExit
|
import androidx.compose.material.icons.filled.FullscreenExit
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -47,6 +49,8 @@ import androidx.compose.material.icons.filled.SkipPrevious
|
|||||||
import androidx.compose.material.icons.filled.Subtitles
|
import androidx.compose.material.icons.filled.Subtitles
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -58,9 +62,23 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.semantics.onClick
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -70,6 +88,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -106,6 +125,7 @@ import uniffi.pstr_android.EpisodeRecord
|
|||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TrackPreferencesRecord
|
import uniffi.pstr_android.TrackPreferencesRecord
|
||||||
import uniffi.pstr_android.skipOffer
|
import uniffi.pstr_android.skipOffer
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** How long the controls stay up after a tap. */
|
/** How long the controls stay up after a tap. */
|
||||||
@@ -114,8 +134,17 @@ private const val CONTROLS_SECONDS = 4L
|
|||||||
/** How long the "up next" card counts down before it loads the next episode. */
|
/** How long the "up next" card counts down before it loads the next episode. */
|
||||||
private const val UP_NEXT_SECONDS = 10
|
private const val UP_NEXT_SECONDS = 10
|
||||||
|
|
||||||
|
/** How far a double tap on either side of the picture seeks. */
|
||||||
|
private const val DOUBLE_TAP_SECONDS = 10
|
||||||
|
|
||||||
|
/** How long the double-tap seek mark stays up. */
|
||||||
|
private const val NUDGE_MS = 700L
|
||||||
|
|
||||||
|
/** How close to the end the next episode's opening blocks are fetched. */
|
||||||
|
private const val WARM_NEXT_SECONDS = 120.0
|
||||||
|
|
||||||
/** Past this much of an episode it counts as watched. */
|
/** Past this much of an episode it counts as watched. */
|
||||||
private const val WATCHED_FRACTION = 0.9
|
internal const val WATCHED_FRACTION = 0.9
|
||||||
|
|
||||||
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||||
private const val MAX_VOLUME = 100f
|
private const val MAX_VOLUME = 100f
|
||||||
@@ -126,23 +155,22 @@ private const val MAX_VOLUME = 100f
|
|||||||
* Below it — a phone held upright — the transport row is already five controls
|
* Below it — a phone held upright — the transport row is already five controls
|
||||||
* wide, and the device's own volume keys are the better answer.
|
* wide, and the device's own volume keys are the better answer.
|
||||||
*/
|
*/
|
||||||
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
|
private val VOLUME_SLIDER_MIN_WIDTH = 720.dp
|
||||||
|
|
||||||
|
/** Narrower than this, the pickers past subtitles go behind "More". */
|
||||||
|
private val INLINE_CONTROLS_MIN_WIDTH = 560.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player, as its own screen on the activity's own window.
|
* The player, as its own screen on the activity's own window.
|
||||||
*
|
*
|
||||||
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
|
* Not a dialog: a dialog window has its own insets and cannot be told to draw
|
||||||
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
|
* into the display cutout. Owning the activity window is what makes
|
||||||
* activity window is what makes "fullscreen" mean the whole panel, notch
|
* "fullscreen" mean the whole panel, notch included.
|
||||||
* included.
|
|
||||||
*
|
*
|
||||||
* [episodes] is the title's episodes in display order and [index] the one that is
|
* [episodes] is the title's episodes in display order and [index] the one that is
|
||||||
* open, which is what previous/next and autoplay walk. The index is hoisted so
|
* open, which is what previous/next and autoplay walk. The index is hoisted so
|
||||||
* that it survives process recreation with the rest of the navigation state —
|
* that it survives process recreation with the rest of the navigation state —
|
||||||
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
|
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
|
||||||
*
|
|
||||||
* In [inPictureInPicture] the window is a thumbnail with the system's own
|
|
||||||
* controls over it, so this screen draws the picture and nothing else.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
@@ -150,7 +178,6 @@ fun PlayerScreen(
|
|||||||
episodes: List<EpisodeRecord>,
|
episodes: List<EpisodeRecord>,
|
||||||
index: Int,
|
index: Int,
|
||||||
host: LibmpvHost?,
|
host: LibmpvHost?,
|
||||||
inPictureInPicture: Boolean,
|
|
||||||
minimized: Boolean,
|
minimized: Boolean,
|
||||||
onIndexChange: (Int) -> Unit,
|
onIndexChange: (Int) -> Unit,
|
||||||
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
||||||
@@ -189,8 +216,16 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val key = mediaKey(episode)
|
val key = mediaKey(episode)
|
||||||
val session = remember(key) { RustStreamSession(episode) }
|
// Retry opens the episode again from scratch: a new stream, through the
|
||||||
|
// bridge's session refresh, from where the failed one stopped.
|
||||||
|
var attempt by remember(key) { mutableIntStateOf(0) }
|
||||||
|
var retryFrom by remember(key) { mutableStateOf<Double?>(null) }
|
||||||
|
val session = remember(key, attempt) { RustStreamSession(episode) }
|
||||||
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
||||||
|
// From the click until mpv has the file: the share session, the revision
|
||||||
|
// and its block list all come first, and on a slow link that is seconds of
|
||||||
|
// black screen unless something says so.
|
||||||
|
var opening by remember(key, attempt) { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(session, host) {
|
LaunchedEffect(session, host) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -209,7 +244,7 @@ fun PlayerScreen(
|
|||||||
size = session.size(),
|
size = session.size(),
|
||||||
// A finished episode restarts rather than resuming three
|
// A finished episode restarts rather than resuming three
|
||||||
// seconds from its own credits.
|
// seconds from its own credits.
|
||||||
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
startPosition = retryFrom ?: watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||||
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
||||||
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
||||||
subtitles = show?.subtitles ?: global.subtitles,
|
subtitles = show?.subtitles ?: global.subtitles,
|
||||||
@@ -223,12 +258,21 @@ fun PlayerScreen(
|
|||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
SettingsStore(context).hardwareDecoding,
|
SettingsStore(context).hardwareDecoding,
|
||||||
)
|
)
|
||||||
|
|
||||||
// After the load, because mpv keeps none of the three across one.
|
// After the load, because mpv keeps none of the three across one.
|
||||||
nativeHost.setVolume(startup.volume)
|
nativeHost.setVolume(startup.volume)
|
||||||
nativeHost.setMuted(startup.muted)
|
nativeHost.setMuted(startup.muted)
|
||||||
nativeHost.setSpeed(startup.speed)
|
nativeHost.setSpeed(startup.speed)
|
||||||
session.transferToPlayer()
|
session.transferToPlayer()
|
||||||
|
// While mpv opens the episode, and off the spinner's path: the host
|
||||||
|
// holds them until it has the file.
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
val files = NativeRuntime.engine()
|
||||||
|
.openSubtitles(episode.shareId, episode.linkId, startup.subtitleLanguage)
|
||||||
|
nativeHost.addSubtitles(key, files, startup.subtitles)
|
||||||
|
}
|
||||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||||
|
opening = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||||
@@ -266,12 +310,8 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(key) {
|
// The five-second save is PlaybackService's, which outlives this screen;
|
||||||
while (true) {
|
// these are the moments only the screen sees.
|
||||||
delay(5_000)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DisposableEffect(key) { onDispose { save() } }
|
DisposableEffect(key) { onDispose { save() } }
|
||||||
|
|
||||||
val next = episodes.getOrNull(index + 1)
|
val next = episodes.getOrNull(index + 1)
|
||||||
@@ -294,15 +334,14 @@ fun PlayerScreen(
|
|||||||
onIndexChange(target)
|
onIndexChange(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the media notification, the lock screen and Picture-in-Picture show.
|
// What the media notification and the lock screen show. Neither can reach
|
||||||
// None of them can reach this composition, and all three have to name the
|
// this composition, and both have to name the episode rather than the app.
|
||||||
// episode rather than the app.
|
|
||||||
LaunchedEffect(key, index, episodes.size, title.key) {
|
LaunchedEffect(key, index, episodes.size, title.key) {
|
||||||
nativeHost.publish(
|
nativeHost.publish(
|
||||||
NowPlaying(
|
NowPlaying(
|
||||||
media = key,
|
media = key,
|
||||||
title = episode.label,
|
title = episode.label,
|
||||||
show = title.canonicalName ?: title.name,
|
show = title.displayName,
|
||||||
detail = episode.detail,
|
detail = episode.detail,
|
||||||
artworkUrl = title.posterUrl ?: title.backdropUrl,
|
artworkUrl = title.posterUrl ?: title.backdropUrl,
|
||||||
artworkFile = episode.thumbnailSource,
|
artworkFile = episode.thumbnailSource,
|
||||||
@@ -324,8 +363,10 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
// Autoplay fires on a clean end of file only, and only for this episode's
|
// Autoplay fires on a clean end of file only, and only for this episode's
|
||||||
// own reading — never on a failure, and never on the outgoing file's.
|
// own reading — never on a failure, and never on the outgoing file's.
|
||||||
|
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||||
|
var stay by remember(key) { mutableStateOf(false) }
|
||||||
LaunchedEffect(state.endReason, state.media) {
|
LaunchedEffect(state.endReason, state.media) {
|
||||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
|
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
|
||||||
}
|
}
|
||||||
// A file mpv could not play ends like any other. Saying so is the whole
|
// A file mpv could not play ends like any other. Saying so is the whole
|
||||||
// difference between a broken episode and an app that silently skipped one.
|
// difference between a broken episode and an app that silently skipped one.
|
||||||
@@ -334,14 +375,42 @@ fun PlayerScreen(
|
|||||||
playbackError = "This episode could not be played."
|
playbackError = "This episode could not be played."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A stall that does not clear is a dropped connection far more often than
|
||||||
|
// a slow one, and "Buffering…" for ever offers no way out of it.
|
||||||
|
LaunchedEffect(state.buffering, key, attempt) {
|
||||||
|
if (state.buffering) {
|
||||||
|
delay(STALL_LIMIT_MS)
|
||||||
|
playbackError = "Still loading. The connection may have dropped."
|
||||||
|
}
|
||||||
|
}
|
||||||
// What mpv itself complained about, which the play call never sees: by the
|
// What mpv itself complained about, which the play call never sees: by the
|
||||||
// time a header turns out to be unreadable it has long since returned.
|
// time a header turns out to be unreadable it has long since returned.
|
||||||
val problem by nativeHost.problem.collectAsState()
|
val problem by nativeHost.problem.collectAsState()
|
||||||
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
||||||
|
|
||||||
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
|
// Whether mpv's readings are this episode's. Not while the next one opens:
|
||||||
|
// the last one's credits play on until it is loaded, and read against the
|
||||||
|
// new index they offered the episode after the next as up next.
|
||||||
|
val playingThis = state.media == key
|
||||||
|
val offer = remember(chapters, key, playingThis, (state.position * 4).roundToInt()) {
|
||||||
|
if (!playingThis) return@remember null
|
||||||
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
// Near the end, fetch the next episode's opening blocks, so autoplay or
|
||||||
|
// "Play now" starts from the cache. The last two minutes or the credits,
|
||||||
|
// whichever comes first: late enough that stopping after one episode has
|
||||||
|
// not cost a download, early enough to matter.
|
||||||
|
val nearEnd = playingThis && state.duration > 0.0 && (
|
||||||
|
state.duration - state.position < WARM_NEXT_SECONDS ||
|
||||||
|
chapters.creditsStart?.let { state.position >= it } == true
|
||||||
|
)
|
||||||
|
LaunchedEffect(key, nearEnd) {
|
||||||
|
val upcoming = next ?: return@LaunchedEffect
|
||||||
|
if (!nearEnd) return@LaunchedEffect
|
||||||
|
runCatching {
|
||||||
|
NativeRuntime.engine().warmEpisode(upcoming.shareId, upcoming.volumeId, upcoming.linkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Auto-skip is offered once per chapter: seeking back into an opening on
|
// Auto-skip is offered once per chapter: seeking back into an opening on
|
||||||
// purpose must not be undone by the setting that skipped it.
|
// purpose must not be undone by the setting that skipped it.
|
||||||
val skipped = remember(key) { mutableSetOf<Long>() }
|
val skipped = remember(key) { mutableSetOf<Long>() }
|
||||||
@@ -352,31 +421,105 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var landscapeLocked by remember { mutableStateOf(false) }
|
var landscapeLocked by remember { mutableStateOf(false) }
|
||||||
|
// Set by the rotation button: from then on the viewer decides.
|
||||||
|
var rotationChosen by remember { mutableStateOf(false) }
|
||||||
|
// A wide picture fills a phone only on its side, so it turns there by
|
||||||
|
// itself, once per episode, until the viewer picks for themselves.
|
||||||
|
LaunchedEffect(key, playingThis, state.videoWidth > state.videoHeight) {
|
||||||
|
if (!playingThis || rotationChosen || landscapeLocked) return@LaunchedEffect
|
||||||
|
if (state.videoHeight > 0.0 && state.videoWidth > state.videoHeight) {
|
||||||
|
landscapeLocked = activity?.lockLandscape() ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(showControls, state.paused) {
|
// TalkBack moves between controls by swiping, and a control that fades
|
||||||
if (showControls && !state.paused) {
|
// four seconds after it is reached cannot be found again.
|
||||||
|
val exploring = rememberTouchExploration()
|
||||||
|
LaunchedEffect(exploring) { if (exploring) showControls = true }
|
||||||
|
LaunchedEffect(showControls, state.paused, exploring) {
|
||||||
|
if (showControls && !state.paused && !exploring) {
|
||||||
delay(CONTROLS_SECONDS * 1_000)
|
delay(CONTROLS_SECONDS * 1_000)
|
||||||
showControls = false
|
showControls = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Everything this screen draws over the picture is sized for a phone panel
|
// Which side was double-tapped, shown for a moment so the seek is seen
|
||||||
// and unreadable in a thumbnail, and Android supplies its own transport
|
// to have happened; the count makes a second tap on one side show again.
|
||||||
// there. Leaving PiP must find the controls up rather than mid-timeout.
|
var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||||
val overlays = !inPictureInPicture
|
LaunchedEffect(nudge) {
|
||||||
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
|
if (nudge != null) {
|
||||||
|
delay(NUDGE_MS)
|
||||||
|
nudge = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val current by rememberUpdatedState(state)
|
||||||
|
// Where a remote's keys land while the controls are down. Once they are
|
||||||
|
// up, the D-pad moves between their buttons as it does anywhere else.
|
||||||
|
val keys = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
|
||||||
if (minimized) return
|
if (minimized) return
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.clickable(
|
// A TV remote, a keyboard: left and right seek as a double tap
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
// does, the middle button brings the controls up, and play/pause
|
||||||
indication = null,
|
// and space do what they say.
|
||||||
enabled = overlays,
|
.focusRequester(keys)
|
||||||
onClick = { showControls = !showControls },
|
.onKeyEvent { event ->
|
||||||
),
|
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionLeft, Key.DirectionRight -> {
|
||||||
|
if (showControls) return@onKeyEvent false
|
||||||
|
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
|
||||||
|
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||||
|
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
|
||||||
|
if (showControls) return@onKeyEvent false
|
||||||
|
showControls = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlayPause, Key.Spacebar -> {
|
||||||
|
host.setPaused(!current.paused)
|
||||||
|
showControls = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.MediaPlay -> { host.setPaused(false); true }
|
||||||
|
Key.MediaPause -> { host.setPaused(true); true }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
// Double-tap a side to seek ten seconds, as every phone video
|
||||||
|
// player has taught; the middle plays or pauses. A single tap
|
||||||
|
// waits out the double-tap timeout, which is the price of both.
|
||||||
|
.pointerInput(host) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { showControls = !showControls },
|
||||||
|
onDoubleTap = { offset ->
|
||||||
|
val third = size.width / 3f
|
||||||
|
val seconds = when {
|
||||||
|
offset.x < third -> -DOUBLE_TAP_SECONDS
|
||||||
|
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
if (seconds == 0) {
|
||||||
|
host.setPaused(!current.paused)
|
||||||
|
} else {
|
||||||
|
host.seek((current.position + seconds).coerceAtLeast(0.0))
|
||||||
|
nudge = seconds to (nudge?.second ?: 0) + 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.semantics {
|
||||||
|
onClick(label = if (showControls) "Hide controls" else "Show controls") {
|
||||||
|
showControls = !showControls
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -393,7 +536,7 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = overlays && showControls,
|
visible = showControls,
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -425,7 +568,7 @@ fun PlayerScreen(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
|
listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
|
||||||
.joinToString(" • "),
|
.joinToString(" • "),
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -450,7 +593,7 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = overlays && showControls,
|
visible = showControls,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
@@ -468,7 +611,10 @@ fun PlayerScreen(
|
|||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
playing = index,
|
playing = index,
|
||||||
onEpisode = { jumpTo(it) },
|
onEpisode = { jumpTo(it) },
|
||||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
onRotate = {
|
||||||
|
rotationChosen = true
|
||||||
|
landscapeLocked = activity?.toggleLandscape() ?: false
|
||||||
|
},
|
||||||
landscapeLocked = landscapeLocked,
|
landscapeLocked = landscapeLocked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -476,7 +622,7 @@ fun PlayerScreen(
|
|||||||
// Drawn whether or not the rest of the controls are up: an opening lasts
|
// Drawn whether or not the rest of the controls are up: an opening lasts
|
||||||
// ninety seconds, and a button you have to wake with a tap is one nobody
|
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||||
// reaches in time.
|
// reaches in time.
|
||||||
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
|
offer?.takeIf { !autoSkip }?.let { skip ->
|
||||||
TonalButton(
|
TonalButton(
|
||||||
onClick = { nativeHost.seek(skip.target) },
|
onClick = { nativeHost.seek(skip.target) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -486,21 +632,59 @@ fun PlayerScreen(
|
|||||||
) { Text(skip.label) }
|
) { Text(skip.label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nudge?.let { (seconds, _) ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(if (seconds < 0) Alignment.CenterStart else Alignment.CenterEnd)
|
||||||
|
.padding(horizontal = 48.dp)
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(if (seconds < 0) "−${-seconds} s" else "+$seconds s", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with no chapters has no credits to count over, so the card
|
||||||
|
// takes its last seconds instead: without it the next episode cut in
|
||||||
|
// with no warning and no way to stay. Counted on the file's own clock,
|
||||||
|
// so it reaches zero at the end of file, which is what advances.
|
||||||
|
val tailLeft = (state.duration - state.position).takeIf {
|
||||||
|
chapters.creditsStart == null && state.duration > UP_NEXT_SECONDS * 6 && it <= UP_NEXT_SECONDS
|
||||||
|
}
|
||||||
UpNextCard(
|
UpNextCard(
|
||||||
next = next,
|
next = next,
|
||||||
visible = overlays && autoplay && !state.paused &&
|
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
||||||
chapters.creditsStart?.let { state.position >= it } == true,
|
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||||
|
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||||
onPlayNow = { advance() },
|
onPlayNow = { advance() },
|
||||||
|
onStay = { stay = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Played to the end with nothing starting after it: the last episode,
|
||||||
|
// autoplay off, or the viewer stayed. A black screen said none of that.
|
||||||
|
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
|
||||||
|
EndCard(
|
||||||
|
finished = episode.label,
|
||||||
|
show = title.displayName,
|
||||||
|
next = next,
|
||||||
|
onPlayNext = { advance() },
|
||||||
|
// mpv unloads a file at its end, so this opens it again.
|
||||||
|
onReplay = { retryFrom = 0.0; attempt++ },
|
||||||
|
onClose = onClose,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Only while nothing else explains the still picture: an error is the
|
// Only while nothing else explains the still picture: an error is the
|
||||||
// more useful thing to say, and a spinner over it reads as "still
|
// more useful thing to say, and a spinner over it reads as "still
|
||||||
// trying".
|
// trying".
|
||||||
if (state.stalled && playbackError == null) {
|
if ((opening || state.stalled) && playbackError == null) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
@@ -512,10 +696,13 @@ fun PlayerScreen(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
)
|
)
|
||||||
if (state.buffering) {
|
if (opening || state.buffering) {
|
||||||
Text(
|
Text(
|
||||||
state.cachePercent.takeIf { it > 0.0 }
|
when {
|
||||||
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
opening -> "Opening ${episode.label}…"
|
||||||
|
state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%"
|
||||||
|
else -> "Buffering…"
|
||||||
|
},
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
@@ -536,14 +723,29 @@ fun PlayerScreen(
|
|||||||
// Dismissible, because the message is not always fatal — a
|
// Dismissible, because the message is not always fatal — a
|
||||||
// subtitle track that failed to load leaves an episode that
|
// subtitle track that failed to load leaves an episode that
|
||||||
// plays perfectly well behind an error that used to be permanent.
|
// plays perfectly well behind an error that used to be permanent.
|
||||||
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
Row {
|
||||||
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
||||||
|
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
}
|
||||||
|
QuietButton(
|
||||||
|
onClick = {
|
||||||
|
retryFrom = state.position.takeIf { state.media == key && it > 0.0 } ?: retryFrom
|
||||||
|
playbackError = null
|
||||||
|
nativeHost.clearProblem()
|
||||||
|
attempt++
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Retry", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long a stall lasts before the player offers to open the episode again. */
|
||||||
|
private const val STALL_LIMIT_MS = 20_000L
|
||||||
|
|
||||||
private data class Startup(
|
private data class Startup(
|
||||||
val nativeHandle: ULong,
|
val nativeHandle: ULong,
|
||||||
val size: ULong,
|
val size: ULong,
|
||||||
@@ -588,6 +790,26 @@ private fun ImmersiveWindow(activity: Activity?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lock to landscape. Reports whether it is. */
|
||||||
|
private fun Activity.lockLandscape(): Boolean {
|
||||||
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether TalkBack, or anything else that explores by touch, is on. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberTouchExploration(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val manager = remember(context) { context.getSystemService(AccessibilityManager::class.java) }
|
||||||
|
var exploring by remember { mutableStateOf(manager?.isTouchExplorationEnabled == true) }
|
||||||
|
DisposableEffect(manager) {
|
||||||
|
val listener = AccessibilityManager.TouchExplorationStateChangeListener { exploring = it }
|
||||||
|
manager?.addTouchExplorationStateChangeListener(listener)
|
||||||
|
onDispose { manager?.removeTouchExplorationStateChangeListener(listener) }
|
||||||
|
}
|
||||||
|
return exploring
|
||||||
|
}
|
||||||
|
|
||||||
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
|
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
|
||||||
private fun Activity.toggleLandscape(): Boolean {
|
private fun Activity.toggleLandscape(): Boolean {
|
||||||
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
@@ -634,8 +856,15 @@ private fun PlayerControls(
|
|||||||
}
|
}
|
||||||
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val roomForVolume = with(density) {
|
val width = with(density) { LocalWindowInfo.current.containerSize.width.toDp() }
|
||||||
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
|
val roomForVolume = width >= VOLUME_SLIDER_MIN_WIDTH
|
||||||
|
// A phone in portrait has room for the transport and two more buttons at
|
||||||
|
// a thumb's size. The rest go behind "More" there rather than off the edge.
|
||||||
|
val roomForAll = width >= INLINE_CONTROLS_MIN_WIDTH
|
||||||
|
val toggleMute = {
|
||||||
|
host.setMuted(!state.muted)
|
||||||
|
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -697,45 +926,80 @@ private fun PlayerControls(
|
|||||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (episodes.size > 1) {
|
if (!roomForAll) {
|
||||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||||
}
|
var more by remember { mutableStateOf(false) }
|
||||||
if (chapters.size > 1) {
|
Box {
|
||||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
|
||||||
}
|
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
|
||||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
val entries = buildList {
|
||||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
|
||||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
|
||||||
CompactIcon(
|
add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
|
||||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
|
||||||
"Mute",
|
add(
|
||||||
) {
|
Triple(
|
||||||
host.setMuted(!state.muted)
|
if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||||
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
if (state.muted) "Unmute" else "Mute",
|
||||||
}
|
toggleMute,
|
||||||
// The app's own volume, which is not the device's: a file
|
),
|
||||||
// mastered quiet is turned up here and stays up for the next
|
)
|
||||||
// one. Only where there is room for it — a phone in portrait
|
add(
|
||||||
// has the system volume keys and no width to spare.
|
Triple(
|
||||||
if (roomForVolume) {
|
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||||
Slider(
|
onRotate,
|
||||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
),
|
||||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
)
|
||||||
onValueChangeFinished = {
|
}
|
||||||
val chosen = dragging?.toDouble()
|
entries.forEach { (icon, label, action) ->
|
||||||
dragging = null
|
DropdownMenuItem(
|
||||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
text = { Text(label) },
|
||||||
},
|
leadingIcon = { Icon(icon, contentDescription = null) },
|
||||||
valueRange = 0f..MAX_VOLUME,
|
onClick = { more = false; action() },
|
||||||
modifier = Modifier.width(96.dp),
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (episodes.size > 1) {
|
||||||
|
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||||
|
}
|
||||||
|
if (chapters.size > 1) {
|
||||||
|
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||||
|
}
|
||||||
|
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||||
|
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||||
|
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||||
|
CompactIcon(
|
||||||
|
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||||
|
if (state.muted) "Unmute" else "Mute",
|
||||||
|
onClick = toggleMute,
|
||||||
|
)
|
||||||
|
// The app's own volume, which is not the device's: a file
|
||||||
|
// mastered quiet is turned up here and stays up for the next
|
||||||
|
// one. Only where there is room for it — a phone in portrait
|
||||||
|
// has the system volume keys and no width to spare.
|
||||||
|
if (roomForVolume) {
|
||||||
|
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||||
|
Slider(
|
||||||
|
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||||
|
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
val chosen = dragging?.toDouble()
|
||||||
|
dragging = null
|
||||||
|
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||||
|
},
|
||||||
|
valueRange = 0f..MAX_VOLUME,
|
||||||
|
modifier = Modifier.width(96.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompactIcon(
|
||||||
|
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||||
|
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||||
|
onClick = onRotate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CompactIcon(
|
|
||||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
|
||||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
|
||||||
onClick = onRotate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -954,6 +1218,7 @@ private fun EpisodeChooser(
|
|||||||
episode.label,
|
episode.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -995,24 +1260,31 @@ private fun EpisodeChooser(
|
|||||||
*
|
*
|
||||||
* "Watch till the end" holds for the rest of the file: someone who wants the
|
* "Watch till the end" holds for the rest of the file: someone who wants the
|
||||||
* post-credits scene should have to say so once, not once every ten seconds.
|
* post-credits scene should have to say so once, not once every ten seconds.
|
||||||
|
*
|
||||||
|
* With [secondsLeft] the card counts down the end of a file that has no credits
|
||||||
|
* chapter, on the file's own clock; the end of file is what advances, and the
|
||||||
|
* second button is "Cancel", since there are no credits to watch.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpNextCard(
|
private fun UpNextCard(
|
||||||
next: EpisodeRecord?,
|
next: EpisodeRecord?,
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
secondsLeft: Int?,
|
||||||
onPlayNow: () -> Unit,
|
onPlayNow: () -> Unit,
|
||||||
|
onStay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var held by remember(next?.linkId) { mutableStateOf(false) }
|
var held by remember(next?.linkId) { mutableStateOf(false) }
|
||||||
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
var counted by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||||
|
val remaining = secondsLeft ?: counted
|
||||||
val upNext = next
|
val upNext = next
|
||||||
val showing = visible && upNext != null && !held
|
val showing = visible && upNext != null && !held
|
||||||
LaunchedEffect(showing) {
|
LaunchedEffect(showing, secondsLeft == null) {
|
||||||
if (!showing) return@LaunchedEffect
|
if (!showing || secondsLeft != null) return@LaunchedEffect
|
||||||
remaining = UP_NEXT_SECONDS
|
counted = UP_NEXT_SECONDS
|
||||||
while (remaining > 0) {
|
while (counted > 0) {
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
remaining -= 1
|
counted -= 1
|
||||||
}
|
}
|
||||||
onPlayNow()
|
onPlayNow()
|
||||||
}
|
}
|
||||||
@@ -1048,6 +1320,7 @@ private fun UpNextCard(
|
|||||||
upNext.label,
|
upNext.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = upNext.thumbnailSource,
|
fallback = upNext.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -1074,8 +1347,49 @@ private fun UpNextCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
if (secondsLeft != null) {
|
||||||
Text("Watch till the end")
|
QuietButton(onClick = { held = true; onStay() }, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text("Watch till the end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the player says once an episode ends and nothing starts after it. */
|
||||||
|
@Composable
|
||||||
|
private fun EndCard(
|
||||||
|
finished: String,
|
||||||
|
show: String,
|
||||||
|
next: EpisodeRecord?,
|
||||||
|
onPlayNext: () -> Unit,
|
||||||
|
onReplay: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.widthIn(max = 360.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
|
||||||
|
.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (next == null) "That was the last episode of $show" else "Finished $finished",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (next != null) {
|
||||||
|
AccentButton(onClick = onPlayNext) { Text("Play next") }
|
||||||
|
} else {
|
||||||
|
QuietButton(onClick = onReplay) { Text("Watch again") }
|
||||||
|
}
|
||||||
|
QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1117,12 +1431,14 @@ private fun CompactIcon(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
|
// 48dp: the smallest target Android's accessibility guidance allows, and
|
||||||
|
// the size a thumb on a moving train actually hits.
|
||||||
|
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
contentDescription = description,
|
contentDescription = description,
|
||||||
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.narl.protonstream.settings
|
package io.narl.protonstream.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The settings that are Android's alone.
|
* The settings that are Android's alone.
|
||||||
@@ -35,6 +36,31 @@ class SettingsStore(context: Context) {
|
|||||||
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||||
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||||
|
|
||||||
|
/** How the library was last sorted, as a `LibrarySort` name; null for the default. */
|
||||||
|
var librarySort: String?
|
||||||
|
get() = preferences.getString(KEY_LIBRARY_SORT, null)
|
||||||
|
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
|
||||||
|
|
||||||
|
/** The library's offer of posters and synopses was turned down. */
|
||||||
|
var metadataOfferDismissed: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
|
||||||
|
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
|
||||||
|
|
||||||
|
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
|
||||||
|
var libraryFilter: String?
|
||||||
|
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
|
||||||
|
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
|
||||||
|
|
||||||
|
/** Whether the library folds each franchise into one tile. */
|
||||||
|
var libraryGrouped: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
||||||
|
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
||||||
|
|
||||||
|
/** Whether a background crawl may say that a show gained episodes. */
|
||||||
|
var newEpisodeNotifications: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
|
||||||
|
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||||
@@ -44,10 +70,20 @@ class SettingsStore(context: Context) {
|
|||||||
|
|
||||||
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||||
|
|
||||||
|
/** Load the file into memory, so no later read waits on the disk. */
|
||||||
|
fun preload(context: Context) {
|
||||||
|
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
|
||||||
|
}
|
||||||
|
|
||||||
private const val NAME = "proton_stream_settings"
|
private const val NAME = "proton_stream_settings"
|
||||||
private const val KEY_WIFI_ONLY = "wifi_only"
|
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||||
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||||
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||||
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||||
|
private const val KEY_LIBRARY_SORT = "library_sort"
|
||||||
|
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
||||||
|
private const val KEY_LIBRARY_FILTER = "library_filter"
|
||||||
|
private const val KEY_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
|
||||||
|
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package io.narl.protonstream.sync
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.narl.protonstream.MainActivity
|
||||||
|
import io.narl.protonstream.R
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import uniffi.pstr_android.NewEpisodesRecord
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look at the shares while the app is away, and say when a show gained episodes.
|
||||||
|
*
|
||||||
|
* The app crawls a share it has not looked at for six hours whenever it opens,
|
||||||
|
* which finds a new episode only once someone opens it to look. This runs the
|
||||||
|
* same crawl on WorkManager's schedule instead. It only reports files the
|
||||||
|
* crawl itself found, so a share the app crawled since says nothing twice.
|
||||||
|
*/
|
||||||
|
class NewEpisodesWorker(
|
||||||
|
appContext: Context,
|
||||||
|
parameters: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
|
||||||
|
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
|
||||||
|
if (news.isNotEmpty()) post(news)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(news: List<NewEpisodesRecord>) {
|
||||||
|
if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = "A show in your library gained episodes"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
Intent(applicationContext, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
val compat = NotificationManagerCompat.from(applicationContext)
|
||||||
|
// One per show, keyed by it, so a second crawl's news for the same show
|
||||||
|
// replaces the first rather than stacking under it.
|
||||||
|
news.forEach { item ->
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
|
.setContentTitle(item.title)
|
||||||
|
.setContentText(describe(item))
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
if (news.size > 1) {
|
||||||
|
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
|
.setContentTitle("New episodes")
|
||||||
|
.setContentText(news.joinToString { it.title })
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setGroup(GROUP)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "new-episodes"
|
||||||
|
private const val CHANNEL_ID = "new-episodes"
|
||||||
|
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
|
||||||
|
private const val SUMMARY_TAG = "new-episodes-summary"
|
||||||
|
private const val NOTIFICATION_ID = 7
|
||||||
|
private const val INTERVAL_HOURS = 6L
|
||||||
|
|
||||||
|
/** What one show's notification says: "S01E04 and 2 more". */
|
||||||
|
fun describe(news: NewEpisodesRecord): String {
|
||||||
|
val more = news.count.toInt() - 1
|
||||||
|
val first = news.first
|
||||||
|
return when {
|
||||||
|
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
|
||||||
|
more == 0 -> "$first is new"
|
||||||
|
more == 1 -> "$first and 1 more are new"
|
||||||
|
else -> "$first and $more more are new"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the periodic look scheduled, or cancel it when the viewer
|
||||||
|
* turned it off. Called on every launch; keeping an existing schedule
|
||||||
|
* means a launch does not push the next look back.
|
||||||
|
*/
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val work = WorkManager.getInstance(context.applicationContext)
|
||||||
|
if (!SettingsStore(context).newEpisodeNotifications) {
|
||||||
|
work.cancelUniqueWork(WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package io.narl.protonstream.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One watch-history sync after the app leaves the screen.
|
||||||
|
*
|
||||||
|
* The model's timer only runs while the app is alive, and Android is free to
|
||||||
|
* kill a process in the background without notice — so the position the
|
||||||
|
* viewer stopped at would wait for the next launch to reach their other
|
||||||
|
* devices. Handed to WorkManager instead, it goes as soon as there is a
|
||||||
|
* network, even if the process is gone by then.
|
||||||
|
*/
|
||||||
|
class WatchSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
parameters: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
return runCatching { engine.syncWatchHistory() }.fold(
|
||||||
|
onSuccess = { Result.success() },
|
||||||
|
onFailure = {
|
||||||
|
// Signed out by the engine — the session ended — is not
|
||||||
|
// something a retry fixes; a dropped connection is.
|
||||||
|
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
|
||||||
|
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "watch-history-sync"
|
||||||
|
private const val MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
/** Replacing: one pending sync covers every position saved before it runs. */
|
||||||
|
fun enqueue(context: Context) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
|
||||||
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context.applicationContext)
|
||||||
|
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Computer
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.FolderShared
|
||||||
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Movie
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
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
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
|
import uniffi.pstr_android.DriveEntryRecord
|
||||||
|
import uniffi.pstr_android.DrivePlaceRecord
|
||||||
|
import uniffi.pstr_android.PlaceType
|
||||||
|
import uniffi.pstr_android.verificationToken
|
||||||
|
|
||||||
|
/** What the account card and its dialogs can ask the model for. */
|
||||||
|
internal data class AccountActions(
|
||||||
|
val signIn: (String, String, String?) -> Unit,
|
||||||
|
val submitSecondFactor: (String) -> Unit,
|
||||||
|
val submitMailboxPassword: (String) -> Unit,
|
||||||
|
val cancelSignIn: () -> Unit,
|
||||||
|
val dismissVerification: () -> Unit,
|
||||||
|
val signOut: () -> Unit,
|
||||||
|
val syncNow: () -> Unit,
|
||||||
|
val addFolder: (String, String, String) -> Unit,
|
||||||
|
val onError: (Throwable) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Proton account, at the top of the shares page: why one would sign in,
|
||||||
|
* or who is signed in and how watch-history sync is doing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AccountCard(
|
||||||
|
account: AccountUiState,
|
||||||
|
actions: AccountActions,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onBrowse: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = account.state ?: return
|
||||||
|
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
if (state is AccountState.SignedIn) {
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
|
||||||
|
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Sync now") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
|
||||||
|
onClick = { menu = false; actions.syncNow() },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Sign out") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
|
||||||
|
onClick = { menu = false; actions.signOut() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
TonalButton(
|
||||||
|
onClick = onBrowse,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Folder, contentDescription = null)
|
||||||
|
Text("Add from your Drive", Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||||
|
"Drive, so every device shows the same library and resumes where another left off, " +
|
||||||
|
"and folders of your Drive can join the library.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TonalButton(onClick = onSignIn) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||||
|
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||||
|
account.syncError?.let { return "Did not sync: $it" }
|
||||||
|
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||||
|
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||||
|
val ago = when {
|
||||||
|
minutes < 1 -> "just now"
|
||||||
|
minutes < 60 -> "$minutes min ago"
|
||||||
|
else -> "${minutes / 60} h ago"
|
||||||
|
}
|
||||||
|
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
|
||||||
|
return "Synced $ago$applied"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in, one step at a time: address and password, then a second-factor
|
||||||
|
* code or the mailbox password if the account has them, with Proton's CAPTCHA
|
||||||
|
* over the top when it asks for one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
var mailbox by remember { mutableStateOf("") }
|
||||||
|
val state = account.state
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state is AccountState.SignedIn) onDismiss()
|
||||||
|
}
|
||||||
|
val dismiss = {
|
||||||
|
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = dismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Sign in to Proton") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
when (state) {
|
||||||
|
AccountState.SecondFactor -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
code,
|
||||||
|
{ code = it },
|
||||||
|
label = { Text("Code from your authenticator app") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||||
|
)
|
||||||
|
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
AccountState.MailboxPassword -> {
|
||||||
|
Text(
|
||||||
|
"This account has a separate mailbox password. It unlocks your files.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
username,
|
||||||
|
{ username = it },
|
||||||
|
label = { Text("Address") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
|
)
|
||||||
|
PasswordField(password, { password = it }, "Password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
when (state) {
|
||||||
|
AccountState.SecondFactor -> AccentButton(
|
||||||
|
onClick = { actions.submitSecondFactor(code.trim()) },
|
||||||
|
enabled = code.isNotBlank() && !account.busy,
|
||||||
|
) { Text("Continue") }
|
||||||
|
AccountState.MailboxPassword -> AccentButton(
|
||||||
|
onClick = { actions.submitMailboxPassword(mailbox) },
|
||||||
|
enabled = mailbox.isNotEmpty() && !account.busy,
|
||||||
|
) { Text("Unlock") }
|
||||||
|
else -> AccentButton(
|
||||||
|
onClick = { actions.signIn(username.trim(), password, null) },
|
||||||
|
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
|
||||||
|
) { Text("Sign in") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
account.verificationUrl?.let { url ->
|
||||||
|
VerificationDialog(
|
||||||
|
url = url,
|
||||||
|
onToken = { token -> actions.signIn(username.trim(), password, token) },
|
||||||
|
onDismiss = actions.dismissVerification,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = { Text(label) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
|
||||||
|
* recognise — a new network, a VPN exit.
|
||||||
|
*
|
||||||
|
* In a WebView rather than the browser because the page reports success by
|
||||||
|
* posting a message to its host, not by redirecting: a browser has nowhere to
|
||||||
|
* post it back to. The page targets `window.parent`, which for a top-level
|
||||||
|
* page is the page itself, so a listener on its own window hears it.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
|
||||||
|
@Composable
|
||||||
|
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"Confirm you are human",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f).padding(start = 16.dp),
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
|
||||||
|
}
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
val main = Handler(Looper.getMainLooper())
|
||||||
|
var answered = false
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun post(message: String) {
|
||||||
|
// Every message the page posts lands here,
|
||||||
|
// most of them its own chatter.
|
||||||
|
val token = verificationToken(message) ?: return
|
||||||
|
main.post {
|
||||||
|
if (!answered) {
|
||||||
|
answered = true
|
||||||
|
onToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pstrVerification",
|
||||||
|
)
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
|
||||||
|
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
|
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bridge above is reachable by whatever the
|
||||||
|
// frame shows, so the frame stays on Proton.
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
|
||||||
|
!isProtonPage(request.url.scheme, request.url.host)
|
||||||
|
}
|
||||||
|
loadUrl(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security keys need WebAuthn, which the sign-in here does not speak. An account
|
||||||
|
* with nothing else is refused before this form (`AccountStore::sign_in`).
|
||||||
|
*/
|
||||||
|
private const val SECURITY_KEY_NOTE =
|
||||||
|
"Security keys are not supported here — use the code from your authenticator app."
|
||||||
|
|
||||||
|
/** Installed once per page, at start and again at finish in case the first ran too early. */
|
||||||
|
private const val VERIFICATION_BRIDGE = """
|
||||||
|
if (!window.__pstrVerification) {
|
||||||
|
window.__pstrVerification = true;
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** Whether a page is Proton's own, over HTTPS. */
|
||||||
|
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
|
||||||
|
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the account's Drive and add a folder of it to the library.
|
||||||
|
*
|
||||||
|
* Full screen, because a Drive is deep and a dialog's height is a few rows of
|
||||||
|
* it. Listings are asked for here rather than through the model: they are
|
||||||
|
* this dialog's alone, and gone when it closes.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun DriveBrowserDialog(
|
||||||
|
actions: AccountActions,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
|
||||||
|
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
|
||||||
|
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val current = trail.lastOrNull()?.second
|
||||||
|
|
||||||
|
LaunchedEffect(current) {
|
||||||
|
error = null
|
||||||
|
if (current == null) {
|
||||||
|
if (places == null) {
|
||||||
|
runCatching { NativeRuntime.engine().drivePlaces() }
|
||||||
|
.onSuccess { places = it }
|
||||||
|
.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries = null
|
||||||
|
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
|
||||||
|
.onSuccess { entries = it }
|
||||||
|
.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
trail.lastOrNull()?.first ?: "Your Drive",
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
|
||||||
|
Icon(
|
||||||
|
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
// A folder already in the library is refused by the engine,
|
||||||
|
// with a message saying so.
|
||||||
|
val folder = current ?: return@Scaffold
|
||||||
|
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
|
||||||
|
AccentButton(
|
||||||
|
onClick = {
|
||||||
|
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text("Add this folder") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
val loading = error == null && (if (current == null) places == null else entries == null)
|
||||||
|
when {
|
||||||
|
error != null -> EmptyState("Could not list this", error.orEmpty())
|
||||||
|
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
|
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
|
||||||
|
DriveRow(
|
||||||
|
icon = when (place.kind) {
|
||||||
|
PlaceType.MY_FILES -> Icons.Default.Storage
|
||||||
|
PlaceType.DEVICE -> Icons.Default.Computer
|
||||||
|
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
|
||||||
|
},
|
||||||
|
name = place.name,
|
||||||
|
detail = null,
|
||||||
|
onClick = { trail.add(place.name to place) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
|
||||||
|
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||||
|
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
|
||||||
|
DriveRow(
|
||||||
|
icon = when {
|
||||||
|
entry.isFolder -> Icons.Default.Folder
|
||||||
|
entry.isVideo -> Icons.Default.Movie
|
||||||
|
else -> Icons.Default.InsertDriveFile
|
||||||
|
},
|
||||||
|
name = entry.name,
|
||||||
|
detail = entry.size?.let(::formatBytes),
|
||||||
|
onClick = if (entry.isFolder) {
|
||||||
|
{
|
||||||
|
trail.add(
|
||||||
|
entry.name to DrivePlaceRecord(
|
||||||
|
PlaceType.MY_FILES,
|
||||||
|
entry.name,
|
||||||
|
entry.volumeId,
|
||||||
|
entry.linkId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DriveRow(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
name: String,
|
||||||
|
detail: String?,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = detail?.let { { Text(it) } },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (onClick != null) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ import kotlinx.coroutines.flow.callbackFlow
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
|
import uniffi.pstr_android.LibraryFilter
|
||||||
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.OfflineRecord
|
import uniffi.pstr_android.OfflineRecord
|
||||||
@@ -34,12 +39,28 @@ import uniffi.pstr_android.MetadataSettingsRecord
|
|||||||
import uniffi.pstr_android.StorageUsageRecord
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.WatchStateRecord
|
import uniffi.pstr_android.WatchStateRecord
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
|
import uniffi.pstr_android.SignInOutcome
|
||||||
|
import uniffi.pstr_android.SyncRecord
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
|
/** A crawl is running, which the banner offers to stop. */
|
||||||
|
val crawling: Boolean = false,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val titles: List<TitleRecord> = emptyList(),
|
val titles: List<TitleRecord> = emptyList(),
|
||||||
|
/**
|
||||||
|
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||||
|
* [grouped], and the shelves above it. Null until the bridge has answered,
|
||||||
|
* when the grid falls back to [titles] as they are.
|
||||||
|
*/
|
||||||
|
val arrangement: ArrangementRecord? = null,
|
||||||
|
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||||
|
val sort: LibrarySort = LibrarySort.NAME,
|
||||||
|
val grouped: Boolean = true,
|
||||||
val shares: List<ShareRecord> = emptyList(),
|
val shares: List<ShareRecord> = emptyList(),
|
||||||
val offline: List<OfflineRecord> = emptyList(),
|
val offline: List<OfflineRecord> = emptyList(),
|
||||||
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
||||||
@@ -50,8 +71,33 @@ data class AppUiState(
|
|||||||
),
|
),
|
||||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
|
/**
|
||||||
|
* What is running in the background right now, said in a banner for as
|
||||||
|
* long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||||
|
* minute-long crawl looked like a tap that did nothing.
|
||||||
|
*/
|
||||||
|
val activity: String? = null,
|
||||||
/** What Undo on [message] puts back, when the message offers one. */
|
/** What Undo on [message] puts back, when the message offers one. */
|
||||||
val undo: List<WatchSnapshot>? = null,
|
val undo: List<WatchSnapshot>? = null,
|
||||||
|
val account: AccountUiState = AccountUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The Proton account: where a sign-in stands, and how sync is doing. */
|
||||||
|
data class AccountUiState(
|
||||||
|
/** Null until the bridge has said. */
|
||||||
|
val state: AccountState? = null,
|
||||||
|
/** A sign-in step is out and not answered yet. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
/** Why the last sign-in step was refused. */
|
||||||
|
val error: String? = null,
|
||||||
|
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
|
||||||
|
val verificationUrl: String? = null,
|
||||||
|
/** When watch history last synced, in epoch milliseconds. */
|
||||||
|
val syncedAt: Long? = null,
|
||||||
|
/** Positions the last sync took from other devices. */
|
||||||
|
val applied: Int = 0,
|
||||||
|
/** Why the last sync failed, until one succeeds. */
|
||||||
|
val syncError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +112,21 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
private val mutableState = MutableStateFlow(AppUiState())
|
private val mutableState = MutableStateFlow(AppUiState())
|
||||||
private val searchQuery = MutableStateFlow("")
|
private val searchQuery = MutableStateFlow("")
|
||||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||||
|
/** Launch and coming back both ask; one crawl answers. */
|
||||||
|
private var catchingUp = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every banner line whose work is still running, oldest first. Touched on
|
||||||
|
* the main thread only, as everything in [viewModelScope] is.
|
||||||
|
*/
|
||||||
|
private val activities = mutableListOf<Pair<Long, String>>()
|
||||||
|
private var nextActivity = 0L
|
||||||
|
|
||||||
|
/** Crawls running now; main thread only, like [activities]. */
|
||||||
|
private var crawlsRunning = 0
|
||||||
|
|
||||||
|
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
|
||||||
|
private var crawlStopped = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!NativeRuntime.tlsReady) {
|
if (!NativeRuntime.tlsReady) {
|
||||||
@@ -73,7 +134,37 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reload()
|
// The arrangement first, so the first grid is drawn the way the viewer
|
||||||
|
// left it rather than A–Z and then re-sorted under them.
|
||||||
|
viewModelScope.launch {
|
||||||
|
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
|
||||||
|
SettingsStore(appContext).run {
|
||||||
|
Triple(
|
||||||
|
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
|
||||||
|
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
|
||||||
|
libraryGrouped,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
catchUp()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||||
|
.getOrDefault(AccountState.SignedOut)
|
||||||
|
updateAccount { it.copy(state = account) }
|
||||||
|
// While the app is alive, on top of after sign-in and whenever the
|
||||||
|
// player closes. Cheap when nothing changed: one folder listing,
|
||||||
|
// and no upload.
|
||||||
|
while (isActive) {
|
||||||
|
syncWatchHistory()
|
||||||
|
// A sync WatchSyncWorker ran while the app was away may have
|
||||||
|
// brought shares this one has not crawled.
|
||||||
|
catchUp()
|
||||||
|
delay(SYNC_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||||
}
|
}
|
||||||
@@ -86,6 +177,29 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
SettingsStore(appContext).apply {
|
||||||
|
librarySort = sort.name
|
||||||
|
libraryGrouped = grouped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reloadLibrary(mutableState.value.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow the grid to some titles, and keep the choice for the next launch. */
|
||||||
|
fun filter(filter: LibraryFilter) {
|
||||||
|
mutableState.update { it.copy(filter = filter) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
|
||||||
|
reloadLibrary(mutableState.value.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
mutableState.update { it.copy(query = query) }
|
mutableState.update { it.copy(query = query) }
|
||||||
searchQuery.value = query
|
searchQuery.value = query
|
||||||
@@ -93,14 +207,72 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
busy("Refreshing the library…") {
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
mutableState.update { it.copy(message = null) }
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
runCatching { crawlShares(null) }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
reload()
|
}
|
||||||
|
reloadNow()
|
||||||
|
matchAfterCrawl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||||
|
* a new episode its stored guide cannot name. When everything already has
|
||||||
|
* one the bridge asks nothing, so this is cheap after every crawl.
|
||||||
|
*/
|
||||||
|
private suspend fun matchAfterCrawl() {
|
||||||
|
val settings = mutableState.value.metadataSettings
|
||||||
|
if (settings.enabled && settings.ready) match(force = false, quiet = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say [what] in the activity banner while [work] runs.
|
||||||
|
*
|
||||||
|
* The banner shows the newest line still running. Work overlaps — a sync
|
||||||
|
* starts during a crawl and ends after it — so each line is removed by
|
||||||
|
* itself rather than by putting back whatever was showing before, which
|
||||||
|
* once brought back a finished crawl's line and spun it forever.
|
||||||
|
*/
|
||||||
|
private suspend fun busy(what: String, work: suspend () -> Unit) {
|
||||||
|
val token = nextActivity++
|
||||||
|
activities += token to what
|
||||||
|
showActivity()
|
||||||
|
try {
|
||||||
|
work()
|
||||||
|
} finally {
|
||||||
|
activities.removeAll { it.first == token }
|
||||||
|
showActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
|
||||||
|
private suspend fun crawlShares(id: String?) {
|
||||||
|
crawlsRunning++
|
||||||
|
mutableState.update { it.copy(crawling = true) }
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
|
||||||
|
} finally {
|
||||||
|
crawlsRunning--
|
||||||
|
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the crawls running now. Shares already read stay in the library;
|
||||||
|
* the one being read keeps what it had.
|
||||||
|
*/
|
||||||
|
fun stopCrawl() {
|
||||||
|
crawlStopped = true
|
||||||
|
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showActivity() {
|
||||||
|
val newest = activities.lastOrNull()?.second
|
||||||
|
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recrawl one share rather than the whole library.
|
* Recrawl one share rather than the whole library.
|
||||||
*
|
*
|
||||||
@@ -111,11 +283,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
*/
|
*/
|
||||||
fun refreshShare(id: String) {
|
fun refreshShare(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
busy("Reading $name…") {
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
mutableState.update { it.copy(message = null) }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
runCatching { crawlShares(id) }
|
||||||
reload()
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
}
|
||||||
|
reloadNow()
|
||||||
|
matchAfterCrawl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,26 +303,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
* changed.
|
* changed.
|
||||||
*/
|
*/
|
||||||
fun matchTitles(force: Boolean) {
|
fun matchTitles(force: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { match(force) }
|
||||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With [quiet], a run nobody asked for says nothing when it found nothing
|
||||||
|
* to do.
|
||||||
|
*/
|
||||||
|
private suspend fun match(force: Boolean, quiet: Boolean = false) {
|
||||||
|
busy("Looking up titles…") {
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||||
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
.onSuccess { summary ->
|
||||||
|
if (!quiet || !summary.isEmpty()) {
|
||||||
|
mutableState.update { it.copy(message = describe(summary)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
mutableState.update { it.copy(refreshing = false) }
|
}
|
||||||
reload()
|
reloadNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a link, and return why it was refused, if it was.
|
||||||
|
*
|
||||||
|
* The link is opened before anything is stored, so the form can stay up
|
||||||
|
* with the reason under it and a retry is a retry — not "already in the
|
||||||
|
* library" for a share that never opened. Only the new share is crawled:
|
||||||
|
* another share that fails to open must not make this one look broken.
|
||||||
|
*/
|
||||||
|
suspend fun addShare(name: String, url: String, password: String?): String? {
|
||||||
|
val share = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||||
|
}
|
||||||
|
}.getOrElse { return it.message ?: "That link could not be added" }
|
||||||
|
reload()
|
||||||
|
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crawl shares that are new here — added on this device or by sync — then
|
||||||
|
* match them when enrichment is on.
|
||||||
|
*/
|
||||||
|
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
|
||||||
|
if (ids.isEmpty()) return
|
||||||
|
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||||
|
val failed = mutableListOf<String>()
|
||||||
|
crawlStopped = false
|
||||||
|
for (id in ids) {
|
||||||
|
if (crawlStopped) break
|
||||||
|
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||||
|
busy("Reading $name…") {
|
||||||
|
runCatching { crawlShares(id) }
|
||||||
|
.onFailure { failed += "$name: ${it.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reloadNow()
|
||||||
|
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||||
|
// the share's row rather than in their face, and only news is said.
|
||||||
|
if (failed.isNotEmpty() && announce) {
|
||||||
|
mutableState.update { it.copy(message = failed.joinToString("\n")) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchAfterCrawl()
|
||||||
|
if (announce) {
|
||||||
|
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
|
||||||
|
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
|
||||||
|
} else {
|
||||||
|
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
|
||||||
|
if (files > 0uL) {
|
||||||
|
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addShare(name: String, url: String, password: String?) {
|
/**
|
||||||
|
* Crawl shares due a look: ones the catalog has nothing of — brought by a
|
||||||
|
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
|
||||||
|
* whose crawl never finished — and ones not crawled for six hours, so new
|
||||||
|
* episodes turn up without a pull to refresh.
|
||||||
|
*/
|
||||||
|
fun catchUp() {
|
||||||
|
if (catchingUp) return
|
||||||
|
catchingUp = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||||
val engine = NativeRuntime.engine()
|
.getOrDefault(emptyList())
|
||||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
crawlAdded(ids, announce = false)
|
||||||
engine.crawl(null)
|
} finally {
|
||||||
}
|
catchingUp = false
|
||||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
}
|
||||||
reload()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +418,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
engine.crawl(id)
|
engine.crawl(id)
|
||||||
}
|
}
|
||||||
}.onFailure(::reportError)
|
}.onFailure(::reportError)
|
||||||
|
reloadNow()
|
||||||
|
matchAfterCrawl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call a share something else. The library, history and downloads stay. */
|
||||||
|
fun renameShare(id: String, name: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
|
||||||
|
.onFailure(::reportError)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,16 +455,187 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
|
||||||
|
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
|
||||||
|
mutableState.update { it.copy(account = change(it.account)) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start signing in, or start again with [verificationToken] once the
|
||||||
|
* CAPTCHA from a first try is solved.
|
||||||
|
*/
|
||||||
|
fun signIn(username: String, password: String, verificationToken: String? = null) =
|
||||||
|
signInStep { engine -> engine.signIn(username, password, verificationToken) }
|
||||||
|
|
||||||
|
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
|
||||||
|
|
||||||
|
fun submitMailboxPassword(password: String) =
|
||||||
|
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
|
||||||
|
|
||||||
|
fun cancelSignIn() {
|
||||||
|
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||||
|
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CAPTCHA page closed without a token. */
|
||||||
|
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
|
||||||
|
|
||||||
|
private fun signInStep(
|
||||||
|
keepStepOnFailure: Boolean = false,
|
||||||
|
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
|
||||||
|
) {
|
||||||
|
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
|
||||||
|
.onSuccess { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
is SignInOutcome.Done -> {
|
||||||
|
updateAccount {
|
||||||
|
AccountUiState(state = AccountState.SignedIn(outcome.username))
|
||||||
|
}
|
||||||
|
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
|
||||||
|
syncWatchHistory()
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
SignInOutcome.SecondFactor ->
|
||||||
|
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
|
||||||
|
SignInOutcome.MailboxPassword ->
|
||||||
|
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
|
||||||
|
is SignInOutcome.HumanVerification ->
|
||||||
|
updateAccount { it.copy(verificationUrl = outcome.url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
updateAccount {
|
||||||
|
it.copy(
|
||||||
|
busy = false,
|
||||||
|
error = error.message ?: "Sign-in failed",
|
||||||
|
// A refused code spends the half-made session.
|
||||||
|
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
|
||||||
|
.onFailure(::reportError)
|
||||||
|
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync now, from a tap: says so when it is done. */
|
||||||
|
fun syncNow() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (syncWatchHistory()) {
|
||||||
|
val applied = mutableState.value.account.applied
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
message = if (applied > 0) {
|
||||||
|
"Watch history synced, $applied from other devices"
|
||||||
|
} else {
|
||||||
|
"Watch history is up to date"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||||
|
* on a timer, and a failure is shown on the account card rather than as a
|
||||||
|
* snackbar every five minutes of an offline evening. Returns whether it
|
||||||
|
* synced.
|
||||||
|
*/
|
||||||
|
private suspend fun syncWatchHistory(): Boolean {
|
||||||
|
if (mutableState.value.account.state !is AccountState.SignedIn) return false
|
||||||
|
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { report ->
|
||||||
|
if (report == null) return false
|
||||||
|
updateAccount {
|
||||||
|
it.copy(
|
||||||
|
syncedAt = System.currentTimeMillis(),
|
||||||
|
applied = report.applied.toInt(),
|
||||||
|
syncError = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
takeSynced(report)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
|
||||||
|
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
|
||||||
|
// Signed out by the engine: the session ended, and that is
|
||||||
|
// worth interrupting for, unlike a dropped connection.
|
||||||
|
if (state == AccountState.SignedOut) reportError(error)
|
||||||
|
false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What another device changed: its shares crawled or dropped here, its
|
||||||
|
* settings repainted.
|
||||||
|
*/
|
||||||
|
private suspend fun takeSynced(report: SyncRecord) {
|
||||||
|
if (report.sharesRemoved.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val downloads = DownloadStateStore(appContext)
|
||||||
|
report.sharesRemoved.forEach { id ->
|
||||||
|
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||||
|
downloads.removeShare(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (report.settingsChanged) AppearanceState.reload()
|
||||||
|
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.sharesRemoved.isNotEmpty()) {
|
||||||
|
reloadNow()
|
||||||
|
}
|
||||||
|
if (report.sharesAdded.isNotEmpty()) {
|
||||||
|
crawlAdded(report.sharesAdded, announce = false)
|
||||||
|
val count = report.sharesAdded.size
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After the player closes: where it stopped is what another device wants next. */
|
||||||
|
fun playerClosed() {
|
||||||
|
reload()
|
||||||
|
viewModelScope.launch { syncWatchHistory() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||||
|
}.onSuccess { share ->
|
||||||
|
reload()
|
||||||
|
crawlAdded(listOf(share.id), announce = true)
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every episode of a title watched or unwatched, with an Undo: a whole
|
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
* menu should not cost them.
|
* menu should not cost them.
|
||||||
*/
|
*/
|
||||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||||
val episodes = title.seasons.flatMap { it.episodes }
|
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some episodes seen or unseen — a season, or a whole title — with an
|
||||||
|
* Undo; [what] names them in the message.
|
||||||
|
*/
|
||||||
|
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
|
||||||
changeWatch(
|
changeWatch(
|
||||||
episodes,
|
episodes.filter { it.watched != watched },
|
||||||
"${title.canonicalName ?: title.name} marked ${if (watched) "watched" else "unwatched"}",
|
"$what marked ${if (watched) "watched" else "unwatched"}",
|
||||||
) { engine, episode, before ->
|
) { engine, episode, before ->
|
||||||
val duration = before?.durationSecs
|
val duration = before?.durationSecs
|
||||||
engine.saveWatchState(
|
engine.saveWatchState(
|
||||||
@@ -225,7 +655,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
|
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
|
||||||
changeWatch(
|
changeWatch(
|
||||||
listOf(episode),
|
listOf(episode),
|
||||||
"${title.canonicalName ?: title.name} removed from Continue watching",
|
"${title.displayName} removed from Continue watching",
|
||||||
) { engine, target, before ->
|
) { engine, target, before ->
|
||||||
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
}
|
}
|
||||||
@@ -238,7 +668,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
|
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
|
||||||
changeWatch(
|
changeWatch(
|
||||||
listOf(episode),
|
listOf(episode),
|
||||||
"${episode.label} of ${title.canonicalName ?: title.name} removed from history",
|
"${episode.label} of ${title.displayName} removed from history",
|
||||||
) { engine, target, before ->
|
) { engine, target, before ->
|
||||||
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
}
|
}
|
||||||
@@ -253,14 +683,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
snapshots.forEach { snapshot ->
|
snapshots.forEach { snapshot ->
|
||||||
val state = snapshot.state
|
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||||
engine.saveWatchState(
|
|
||||||
snapshot.shareId,
|
|
||||||
snapshot.linkId,
|
|
||||||
state?.positionSecs ?: 0.0,
|
|
||||||
state?.durationSecs,
|
|
||||||
state?.watched ?: false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure(::reportError)
|
}.onFailure(::reportError)
|
||||||
@@ -294,22 +717,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
/**
|
||||||
viewModelScope.launch {
|
* Store the enrichment settings, and return why not, if they were not.
|
||||||
runCatching {
|
*
|
||||||
withContext(Dispatchers.IO) {
|
* The page shows the new settings as soon as they are stored; the match
|
||||||
val engine = NativeRuntime.engine()
|
* run that follows can take minutes and says so in the activity banner.
|
||||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
*/
|
||||||
engine.setMetadataApiKey(provider, apiKey)
|
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||||
}
|
val stored = runCatching {
|
||||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
withContext(Dispatchers.IO) {
|
||||||
if (enabled) engine.matchTitles(false) else null
|
val engine = NativeRuntime.engine()
|
||||||
|
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||||
|
engine.setMetadataApiKey(provider, apiKey)
|
||||||
}
|
}
|
||||||
}.onSuccess { summary ->
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
engine.metadataSettings()
|
||||||
}.onFailure(::reportError)
|
}
|
||||||
reload()
|
}.getOrElse { return it.message ?: "The settings could not be saved" }
|
||||||
}
|
mutableState.update { it.copy(metadataSettings = stored) }
|
||||||
|
reloadNow()
|
||||||
|
if (enabled) viewModelScope.launch { match(force = false) }
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadAfterMetadataChange() = reload()
|
fun reloadAfterMetadataChange() = reload()
|
||||||
@@ -392,24 +820,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
* at its duration where one is known, so the progress bar agrees with the
|
* at its duration where one is known, so the progress bar agrees with the
|
||||||
* tick.
|
* tick.
|
||||||
*/
|
*/
|
||||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
/** One episode's tick, with the same Undo a season's has. */
|
||||||
viewModelScope.launch {
|
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||||
runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val engine = NativeRuntime.engine()
|
|
||||||
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
|
||||||
engine.saveWatchState(
|
|
||||||
episode.shareId,
|
|
||||||
episode.linkId,
|
|
||||||
if (watched) duration ?: 0.0 else 0.0,
|
|
||||||
duration,
|
|
||||||
watched,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure(::reportError)
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAllOffline() {
|
fun removeAllOffline() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -436,43 +848,60 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reload() {
|
private fun reload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { reloadNow() }
|
||||||
runCatching {
|
}
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val engine = NativeRuntime.engine()
|
/** [reload], for a caller that goes on to read what it loaded. */
|
||||||
// Here rather than inside `library()`, which is read-only
|
private suspend fun reloadNow() {
|
||||||
// and cached: a full reload is the one place that has
|
runCatching {
|
||||||
// already paid for a walk of the offline files.
|
withContext(Dispatchers.IO) {
|
||||||
engine.pruneOfflineFiles()
|
val engine = NativeRuntime.engine()
|
||||||
Reloaded(
|
// Here rather than inside `library()`, which is read-only
|
||||||
engine.shares(),
|
// and cached: a full reload is the one place that has
|
||||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
// already paid for a walk of the offline files.
|
||||||
engine.offlineFiles(),
|
engine.pruneOfflineFiles()
|
||||||
engine.metadataSettings(),
|
val current = mutableState.value
|
||||||
engine.storageUsage(),
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
)
|
Reloaded(
|
||||||
}
|
engine.shares(),
|
||||||
}.onSuccess { result ->
|
engine.library(query),
|
||||||
mutableState.update { it.copy(
|
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||||
loading = false,
|
engine.offlineFiles(),
|
||||||
shares = result.shares,
|
engine.metadataSettings(),
|
||||||
titles = result.titles,
|
engine.storageUsage(),
|
||||||
offline = result.offline,
|
)
|
||||||
metadataSettings = result.metadataSettings,
|
}
|
||||||
storage = result.storage,
|
}.onSuccess { result ->
|
||||||
) }
|
mutableState.update { it.copy(
|
||||||
}
|
loading = false,
|
||||||
.onFailure { error ->
|
shares = result.shares,
|
||||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
titles = result.titles,
|
||||||
}
|
arrangement = result.arrangement,
|
||||||
}
|
offline = result.offline,
|
||||||
|
metadataSettings = result.metadataSettings,
|
||||||
|
storage = result.storage,
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadLibrary(query: String) {
|
private suspend fun reloadLibrary(query: String) {
|
||||||
|
val current = mutableState.value
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
|
withContext(Dispatchers.IO) {
|
||||||
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
|
val engine = NativeRuntime.engine()
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
val search = query.takeIf(String::isNotBlank)
|
||||||
|
engine.library(search) to engine.arrangement(search, current.filter, current.sort, current.grouped)
|
||||||
|
}
|
||||||
|
}.onSuccess { (titles, arrangement) ->
|
||||||
|
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||||
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||||
@@ -484,6 +913,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
private data class Reloaded(
|
private data class Reloaded(
|
||||||
val shares: List<ShareRecord>,
|
val shares: List<ShareRecord>,
|
||||||
val titles: List<TitleRecord>,
|
val titles: List<TitleRecord>,
|
||||||
|
val arrangement: ArrangementRecord,
|
||||||
val offline: List<OfflineRecord>,
|
val offline: List<OfflineRecord>,
|
||||||
val metadataSettings: MetadataSettingsRecord,
|
val metadataSettings: MetadataSettingsRecord,
|
||||||
val storage: StorageUsageRecord,
|
val storage: StorageUsageRecord,
|
||||||
|
|||||||
@@ -1,31 +1,139 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarColors
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.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
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab's page: its own name in an app bar, over whatever the page holds.
|
||||||
|
*
|
||||||
|
* One bar for the whole app said "proton-stream" on every page, which is a band
|
||||||
|
* of height spent naming the app the viewer is already in. The bar draws under
|
||||||
|
* the status bar itself, which is why the shell hands pages no top padding;
|
||||||
|
* [content] gets the rest of [padding] (the navigation bar and the mini
|
||||||
|
* transport) to apply as it scrolls.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun EmptyState(title: String, body: String) {
|
internal fun TabPage(
|
||||||
|
title: String,
|
||||||
|
padding: PaddingValues,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
// Pinned: the bar stays, and takes the container tint once the page has
|
||||||
|
// scrolled under it, so the edge between the two is still visible.
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
colors = pageBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f)) { content(padding) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's own background until something scrolls under the bar, then the
|
||||||
|
* container tint. A bar in `surface` from the start read as a band across the
|
||||||
|
* top of a page drawn in `background`.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared-element scopes the library-to-title switch runs in, or null where
|
||||||
|
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
|
||||||
|
* than parameters, because the poster that animates is three calls deep in the
|
||||||
|
* grid and nothing between needs to know.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
|
||||||
|
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title's art as one element across the switch: the poster tapped in the
|
||||||
|
* grid grows into the backdrop of the page it opens, and shrinks back on the
|
||||||
|
* way out, so the page is visibly *that* title's rather than a new screen.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.sharedArt(key: String): Modifier {
|
||||||
|
val transition = LocalSharedTransition.current ?: return this
|
||||||
|
val visibility = LocalPaneVisibility.current ?: return this
|
||||||
|
return with(transition) {
|
||||||
|
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EmptyState(title: String, body: String, action: Pair<String, () -> Unit>? = null) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
Text(
|
||||||
|
body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
action?.let { (label, onClick) ->
|
||||||
|
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where Play on a title lands: the most recently played part-watched episode,
|
* Where Play on a title lands: what Continue watching offers
|
||||||
* else the first unwatched one, else the first — the desktop's `next_up`.
|
* ([TitleRecord.continueIndex]), else the most recently played part-watched
|
||||||
|
* episode, else the first unwatched one, else the first — the desktop's
|
||||||
|
* `next_up`.
|
||||||
*/
|
*/
|
||||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||||
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
continueIndex?.toInt()?.takeIf { it in playlist.indices }
|
||||||
|
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||||
?: 0
|
?: 0
|
||||||
|
|
||||||
@@ -38,3 +146,95 @@ internal fun formatBytes(bytes: ULong): String {
|
|||||||
else -> "$bytes bytes"
|
else -> "$bytes bytes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@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())}"
|
||||||
|
},
|
||||||
|
download.bytesPerSecond
|
||||||
|
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
|
||||||
|
?.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".
|
||||||
|
*
|
||||||
|
* The provider's name for the episode, else the name the filename gave it,
|
||||||
|
* else "Episode 1". The bare filename only when none of those exist — a column
|
||||||
|
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
||||||
|
*/
|
||||||
|
internal fun EpisodeRecord.heading(position: Int): String {
|
||||||
|
val named = providerName ?: detail.takeIf { it != name }
|
||||||
|
val number = number?.toInt()
|
||||||
|
return when {
|
||||||
|
named != null && number != null -> "$number. $named"
|
||||||
|
named != null -> named
|
||||||
|
number != null -> "Episode $number"
|
||||||
|
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +1,67 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.Card
|
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
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import io.narl.protonstream.download.DownloadCoordinator
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
import io.narl.protonstream.download.DownloadStateStore
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
import io.narl.protonstream.download.RetainedDownload
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.ui.theme.DangerButton
|
||||||
import io.narl.protonstream.ui.theme.AccentProgress
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
import io.narl.protonstream.ui.theme.QuietButton
|
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
|
||||||
|
/** One show's saved episodes, and the show itself where the library has it. */
|
||||||
|
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DownloadsScreen(
|
internal fun DownloadsScreen(
|
||||||
state: AppUiState,
|
state: AppUiState,
|
||||||
onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit,
|
onRemove: (OfflineRecord) -> Unit,
|
||||||
onPause: (RetainedDownload) -> Unit,
|
onPause: (RetainedDownload) -> Unit,
|
||||||
onResume: (RetainedDownload) -> Unit,
|
onResume: (RetainedDownload) -> Unit,
|
||||||
onDeletePartial: (RetainedDownload) -> Unit,
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||||
|
onOpen: (TitleRecord) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||||
@@ -57,83 +73,189 @@ internal fun DownloadsScreen(
|
|||||||
// but not its show, and the library is the only thing that does.
|
// but not its show, and the library is the only thing that does.
|
||||||
val groups = remember(state.offline, state.titles) {
|
val groups = remember(state.offline, state.titles) {
|
||||||
val shows = state.titles.flatMap { title ->
|
val shows = state.titles.flatMap { title ->
|
||||||
title.seasons.flatMap(SeasonRecord::episodes).map {
|
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||||
"${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name)
|
|
||||||
}
|
|
||||||
}.toMap()
|
}.toMap()
|
||||||
state.offline
|
state.offline
|
||||||
.groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
|
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||||
.toSortedMap()
|
.map { (title, files) ->
|
||||||
|
OfflineGroup(title?.displayName ?: "Not in the library", title, files)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
}
|
}
|
||||||
LazyColumn(
|
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||||
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
// keep it, and getting it back is a download, not a tap.
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
|
||||||
) {
|
TabPage("Downloads", padding) { body ->
|
||||||
item {
|
|
||||||
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
if (retained.isEmpty() && state.offline.isEmpty()) {
|
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||||
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||||
|
}
|
||||||
|
return@TabPage
|
||||||
}
|
}
|
||||||
// Under the show they belong to, as on desktop: a saved season is
|
LazyColumn(
|
||||||
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
modifier = Modifier.fillMaxSize().padding(body),
|
||||||
groups.forEach { (show, files) ->
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
item(key = "group/$show") {
|
) {
|
||||||
Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
|
if (state.offline.isNotEmpty()) {
|
||||||
|
item(key = "summary") {
|
||||||
|
Text(
|
||||||
|
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
|
||||||
|
"${formatBytes(state.offline.sumOf { it.size })} on this device",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What is still arriving comes first: it is the part of the page
|
||||||
|
// that changes, and the part with something to do.
|
||||||
|
if (retained.isNotEmpty()) {
|
||||||
|
item(key = "in-progress") { SectionHeading("In progress") }
|
||||||
|
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
|
||||||
|
PartialRow(download, onPause, onResume, onDeletePartial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Under the show they belong to, as on desktop: a saved season is
|
||||||
|
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
||||||
|
groups.forEach { group ->
|
||||||
|
item(key = "group/${group.name}") {
|
||||||
|
SectionHeading(
|
||||||
|
group.name,
|
||||||
|
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
|
||||||
|
formatBytes(group.files.sumOf { it.size }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||||
|
SavedRow(file, group.title, onDelete = { deleting = file }, onPlay = onPlay, onOpen = onOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleting?.let { file ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleting = null },
|
||||||
|
title = { Text("Delete this download?") },
|
||||||
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
|
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
|
||||||
formatBytes(files.sumOf { it.size }),
|
"It still streams, and can be downloaded again.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
confirmButton = {
|
||||||
val episode = file.episode
|
DangerButton(onClick = {
|
||||||
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
deleting = null
|
||||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
onRemove(file)
|
||||||
Column(Modifier.weight(1f)) {
|
}) { Text("Delete") }
|
||||||
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
|
},
|
||||||
Text(formatBytes(file.size))
|
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||||
}
|
)
|
||||||
TonalButton(onClick = { onRemove(file) }) {
|
}
|
||||||
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Delete")
|
@Composable
|
||||||
}
|
private fun SectionHeading(title: String, caption: String? = null) {
|
||||||
}
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) {
|
||||||
}
|
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
caption?.let {
|
||||||
}
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
items(retained, key = { "${it.shareId}/${it.linkId}" }) { download ->
|
|
||||||
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
|
|
||||||
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
|
||||||
Column(Modifier.padding(16.dp)) {
|
|
||||||
Text(download.label, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(download.status.replaceFirstChar { it.uppercase() })
|
|
||||||
if (download.total > 0L) {
|
|
||||||
AccentProgress(
|
|
||||||
progress = { progress },
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
) {
|
|
||||||
if (download.status == RetainedDownload.STATUS_RUNNING ||
|
|
||||||
download.status == RetainedDownload.STATUS_QUEUED
|
|
||||||
) {
|
|
||||||
TonalButton(onClick = { onPause(download) }) { Text("Pause") }
|
|
||||||
} else {
|
|
||||||
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
|
|
||||||
}
|
|
||||||
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
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,
|
||||||
|
title?.name ?: file.linkId,
|
||||||
|
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
|
||||||
|
fallback = episode?.thumbnailSource,
|
||||||
|
labelled = episode == null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A download not yet finished: how far it has got, why it stopped if it did,
|
||||||
|
* and pause or resume beside cancel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PartialRow(
|
||||||
|
download: RetainedDownload,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
) {
|
||||||
|
val active = download.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||||
|
if (download.total > 0L) {
|
||||||
|
AccentProgress(
|
||||||
|
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (active) {
|
||||||
|
IconButton(onClick = { onPause(download) }) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { onResume(download) }) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What has arrived so far is discarded; nothing finished is.
|
||||||
|
IconButton(onClick = { onDeletePartial(download) }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,28 +80,32 @@ internal fun HistoryScreen(
|
|||||||
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||||
}
|
}
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
TabPage("History", padding) { body ->
|
||||||
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = 24.dp)) {
|
TabPage("History", padding) { body ->
|
||||||
days.forEach { (day, played) ->
|
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
item(key = "day/$day") {
|
days.forEach { (day, played) ->
|
||||||
Text(
|
item(key = "day/$day") {
|
||||||
dayHeading(day, today),
|
Text(
|
||||||
style = MaterialTheme.typography.titleSmall,
|
dayHeading(day, today),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
||||||
}
|
)
|
||||||
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
}
|
||||||
HistoryRow(
|
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||||
entry,
|
HistoryRow(
|
||||||
onPlay = { onPlay(entry.title, entry.index) },
|
entry,
|
||||||
onTitle = { onTitle(entry.title) },
|
onPlay = { onPlay(entry.title, entry.index) },
|
||||||
onRemove = { onRemove(entry.title, entry.episode) },
|
onTitle = { onTitle(entry.title) },
|
||||||
)
|
onRemove = { onRemove(entry.title, entry.episode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
|
|||||||
episode.label,
|
episode.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -140,7 +145,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
|
|||||||
}
|
}
|
||||||
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
entry.title.canonicalName ?: entry.title.name,
|
entry.title.displayName,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The languages offered by name, as the three-letter tag a Matroska file
|
||||||
|
* carries.
|
||||||
|
*
|
||||||
|
* The same short list the desktop names tracks from (`pstr-player`'s
|
||||||
|
* `language_name`): what the shares this app is pointed at actually contain.
|
||||||
|
* mpv treats the two- and three-letter forms of a language as one, so the tag
|
||||||
|
* chosen here also matches a file that says "ja". Anything else is still one
|
||||||
|
* typed tag away.
|
||||||
|
*/
|
||||||
|
internal val LANGUAGES: List<Pair<String, String>> = listOf(
|
||||||
|
"jpn" to "Japanese",
|
||||||
|
"eng" to "English",
|
||||||
|
"ger" to "German",
|
||||||
|
"fre" to "French",
|
||||||
|
"spa" to "Spanish",
|
||||||
|
"ita" to "Italian",
|
||||||
|
"por" to "Portuguese",
|
||||||
|
"dut" to "Dutch",
|
||||||
|
"rus" to "Russian",
|
||||||
|
"pol" to "Polish",
|
||||||
|
"swe" to "Swedish",
|
||||||
|
"dan" to "Danish",
|
||||||
|
"nor" to "Norwegian",
|
||||||
|
"fin" to "Finnish",
|
||||||
|
"cze" to "Czech",
|
||||||
|
"hun" to "Hungarian",
|
||||||
|
"tur" to "Turkish",
|
||||||
|
"ara" to "Arabic",
|
||||||
|
"heb" to "Hebrew",
|
||||||
|
"hin" to "Hindi",
|
||||||
|
"kor" to "Korean",
|
||||||
|
"chi" to "Chinese",
|
||||||
|
"tha" to "Thai",
|
||||||
|
"vie" to "Vietnamese",
|
||||||
|
"ukr" to "Ukrainian",
|
||||||
|
"rum" to "Romanian",
|
||||||
|
"gre" to "Greek",
|
||||||
|
"ind" to "Indonesian",
|
||||||
|
"may" to "Malay",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Japanese", for a tag in any of the forms a file or the desktop may have
|
||||||
|
* stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
|
||||||
|
* Null is no preference.
|
||||||
|
*/
|
||||||
|
internal fun languageLabel(tag: String?): String {
|
||||||
|
val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference"
|
||||||
|
return languageOf(value)?.second ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The listed language a tag names, matching either ISO 639 form. */
|
||||||
|
private fun languageOf(tag: String): Pair<String, String>? {
|
||||||
|
val base = tag.split('-', '_').first().lowercase()
|
||||||
|
return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 639-2/T, for the handful whose bibliographic code differs. */
|
||||||
|
private fun terminology(code: String): String? = when (code) {
|
||||||
|
"ger" -> "deu"
|
||||||
|
"fre" -> "fra"
|
||||||
|
"dut" -> "nld"
|
||||||
|
"cze" -> "ces"
|
||||||
|
"chi" -> "zho"
|
||||||
|
"rum" -> "ron"
|
||||||
|
"gre" -> "ell"
|
||||||
|
"may" -> "msa"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 639-1, the form a file or the desktop may also have stored. */
|
||||||
|
private val TWO_LETTER = mapOf(
|
||||||
|
"jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it",
|
||||||
|
"por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da",
|
||||||
|
"nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar",
|
||||||
|
"heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi",
|
||||||
|
"ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A language preference as a row, opening a list to choose from.
|
||||||
|
*
|
||||||
|
* It was a free-text field asking for "jpn or eng", which is a question only
|
||||||
|
* someone who has read a Matroska header can answer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) {
|
||||||
|
var choosing by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(languageLabel(value)) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(role = Role.Button) { choosing = true },
|
||||||
|
)
|
||||||
|
if (choosing) {
|
||||||
|
LanguageDialog(
|
||||||
|
headline,
|
||||||
|
value,
|
||||||
|
onDismiss = { choosing = false },
|
||||||
|
onChoose = {
|
||||||
|
choosing = false
|
||||||
|
onChange(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) {
|
||||||
|
val current = value?.let(::languageOf)?.first ?: value
|
||||||
|
// A tag that is not on the list starts in the field, so it is shown and
|
||||||
|
// can be edited rather than silently replaced.
|
||||||
|
var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(headline) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
|
||||||
|
item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } }
|
||||||
|
items(LANGUAGES, key = { it.first }) { (code, name) ->
|
||||||
|
LanguageOption(name, current == code) { onChoose(code) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it },
|
||||||
|
label = { Text("Another tag") },
|
||||||
|
supportingText = { Text("As it appears in the file, such as \"tgl\"") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) {
|
||||||
|
Text("Use tag")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) },
|
||||||
|
leadingContent = { RadioButton(selected = selected, onClick = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||||
@@ -21,8 +39,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import io.narl.protonstream.download.DownloadCoordinator
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -44,10 +60,10 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
@@ -57,13 +73,23 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.ui.theme.AccentProgress
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.LibraryFilter
|
||||||
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TitleType
|
import uniffi.pstr_android.TitleType
|
||||||
@@ -77,7 +103,52 @@ import uniffi.pstr_android.TitleType
|
|||||||
private const val CONTINUE_WATCHING_MAX = 12
|
private const val CONTINUE_WATCHING_MAX = 12
|
||||||
|
|
||||||
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||||
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
private data class Resumable(
|
||||||
|
val title: TitleRecord,
|
||||||
|
val episode: EpisodeRecord,
|
||||||
|
val index: Int,
|
||||||
|
/** When the title was last played, which orders the shelf. */
|
||||||
|
val lastPlayed: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
|
||||||
|
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>)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -90,60 +161,89 @@ internal fun LibraryScreen(
|
|||||||
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
||||||
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
onMatchChanged: () -> Unit = {},
|
||||||
|
onError: (Throwable) -> Unit = {},
|
||||||
|
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||||
|
onAddShare: () -> Unit = {},
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val download = rememberDownload()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
// Posters and synopses, offered once there is a library to put them on
|
||||||
|
// and never turned on unasked: switching them on sends title names out.
|
||||||
|
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
|
||||||
// What a long press opened a menu for: a title, or a Continue watching card.
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||||
|
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
// Most recently played first, one episode per show: a shelf that lists four
|
// Most recently played first, one episode per show: a shelf that lists four
|
||||||
// episodes of the same series is a shelf with room for nothing else.
|
// episodes of the same series is a shelf with room for nothing else.
|
||||||
|
// What to go on with is the engine's call (`continueIndex`): the
|
||||||
|
// part-watched episode, or the one after the episode finished last.
|
||||||
val resumable = remember(state.titles) {
|
val resumable = remember(state.titles) {
|
||||||
state.titles.mapNotNull { title ->
|
state.titles.mapNotNull { title ->
|
||||||
|
val index = title.continueIndex?.toInt() ?: return@mapNotNull null
|
||||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
playlist.withIndex()
|
playlist.getOrNull(index)?.let { episode ->
|
||||||
.filter { it.value.resumeAt != null && !it.value.watched }
|
Resumable(title, episode, index, playlist.maxOf { it.lastPlayed })
|
||||||
.maxByOrNull { it.value.lastPlayed }
|
}
|
||||||
?.let { Resumable(title, it.value, it.index) }
|
}.sortedByDescending { it.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
|
||||||
}
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
// Search is an icon until it is wanted: a permanent field above the
|
||||||
// A filled pill rather than an outlined form field: search is how the
|
// library spent a band of every visit on something used in few of them.
|
||||||
// page is navigated, not a value being entered.
|
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
|
||||||
TextField(
|
val closeSearch = {
|
||||||
value = state.query,
|
searching = false
|
||||||
onValueChange = onSearch,
|
onSearch("")
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
}
|
||||||
singleLine = true,
|
BackHandler(enabled = searching, onBack = closeSearch)
|
||||||
shape = CircleShape,
|
// The large title folds into the bar as the grid scrolls, and comes back
|
||||||
placeholder = { Text("Search library") },
|
// only at the top: the page says what it is on arrival, then gets out of
|
||||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
// the way.
|
||||||
trailingIcon = {
|
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
if (state.query.isNotEmpty()) {
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
IconButton(onClick = { onSearch("") }) {
|
if (searching) {
|
||||||
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
SearchBar(state.query, onSearch, closeSearch)
|
||||||
|
} else {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text("Library") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { searching = true }) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||||
}
|
}
|
||||||
}
|
ArrangeMenu(state.sort, state.grouped, onArrange)
|
||||||
},
|
// Pull to refresh is the gesture; the icon stays for a
|
||||||
colors = TextFieldDefaults.colors(
|
// viewer who does not know it.
|
||||||
focusedIndicatorColor = Color.Transparent,
|
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
disabledIndicatorColor = Color.Transparent,
|
}
|
||||||
),
|
},
|
||||||
)
|
colors = pageBarColors(),
|
||||||
// Pull to refresh, the gesture every list on a phone answers to; the
|
scrollBehavior = scroll,
|
||||||
// icon in the bar stays for a viewer who does not know it.
|
)
|
||||||
|
}
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = state.refreshing,
|
isRefreshing = state.refreshing,
|
||||||
onRefresh = onRefresh,
|
onRefresh = onRefresh,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.weight(1f).padding(padding),
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
state.loading -> LibrarySkeleton()
|
||||||
CircularProgressIndicator()
|
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
||||||
}
|
"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(
|
state.titles.isEmpty() -> EmptyState(
|
||||||
"Your library is empty",
|
"Your library is empty",
|
||||||
"Add a Proton Drive public link under Shares, then refresh.",
|
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||||
|
action = "Add a share" to onAddShare,
|
||||||
)
|
)
|
||||||
else -> LibraryGrid(
|
else -> LibraryGrid(
|
||||||
state,
|
state,
|
||||||
@@ -152,6 +252,15 @@ internal fun LibraryScreen(
|
|||||||
onTitle,
|
onTitle,
|
||||||
onTitleMenu = { menuFor = it },
|
onTitleMenu = { menuFor = it },
|
||||||
onResumeMenu = { menuResume = it },
|
onResumeMenu = { menuResume = it },
|
||||||
|
onFilter = onFilter,
|
||||||
|
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
MetadataOffer(onOpenSettings) {
|
||||||
|
offerDismissed = true
|
||||||
|
SettingsStore(context).metadataOfferDismissed = true
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,21 +268,35 @@ internal fun LibraryScreen(
|
|||||||
menuFor?.let { title ->
|
menuFor?.let { title ->
|
||||||
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||||
TileMenu(title.canonicalName ?: title.name, title.caption(), onDismiss = { menuFor = null }) {
|
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||||
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||||
onResume(title, nextUpIndex(playlist))
|
onResume(title, nextUpIndex(playlist, title.continueIndex))
|
||||||
}
|
}
|
||||||
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||||
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) }
|
||||||
MenuRow(
|
MenuRow(
|
||||||
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
if (allWatched) "Mark unwatched" else "Mark watched",
|
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||||
) { onSetTitleWatched(title, !allWatched) }
|
) { onSetTitleWatched(title, !allWatched) }
|
||||||
|
// The same search the title page offers, for the tile that is
|
||||||
|
// plainly wrong — the wrong poster is what the grid shows.
|
||||||
|
MenuRow(Icons.Default.Edit, "Change match") { matching = title }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
matching?.let { title ->
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { matching = null },
|
||||||
|
onChanged = {
|
||||||
|
matching = null
|
||||||
|
onMatchChanged()
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
|
}
|
||||||
menuResume?.let { entry ->
|
menuResume?.let { entry ->
|
||||||
TileMenu(
|
TileMenu(
|
||||||
entry.title.canonicalName ?: entry.title.name,
|
entry.title.displayName,
|
||||||
entry.episode.label,
|
entry.episode.label,
|
||||||
onDismiss = { menuResume = null },
|
onDismiss = { menuResume = null },
|
||||||
) {
|
) {
|
||||||
@@ -186,6 +309,100 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's order, and whether a franchise is one tile or one per title —
|
||||||
|
* the same choices the desktop offers beside its grid, behind one icon here
|
||||||
|
* because a phone's bar has room for three.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ArrangeMenu(sort: LibrarySort, grouped: Boolean, onArrange: (LibrarySort, Boolean) -> Unit) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { open = true }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort library")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
LibrarySort.entries.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option.label()) },
|
||||||
|
leadingIcon = {
|
||||||
|
if (option == sort) Icon(Icons.Default.Check, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onArrange(option, grouped)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("One tile per franchise") },
|
||||||
|
leadingIcon = {
|
||||||
|
if (grouped) Icon(Icons.Default.Check, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onArrange(sort, !grouped)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each order is called. The desktop says the same. */
|
||||||
|
private fun LibrarySort.label() = when (this) {
|
||||||
|
LibrarySort.NAME -> "A – Z"
|
||||||
|
LibrarySort.RECENT -> "Recently watched"
|
||||||
|
LibrarySort.ADDED -> "Recently added"
|
||||||
|
LibrarySort.RELEASE -> "Newest"
|
||||||
|
LibrarySort.RATING -> "Highest rated"
|
||||||
|
LibrarySort.POPULARITY -> "Most popular"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bar while searching: back closes the search and clears it, and the field
|
||||||
|
* takes the keyboard as soon as it opens, since opening it is asking to type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
// A filled pill rather than an outlined form field: search is how
|
||||||
|
// the page is navigated, not a value being entered.
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onSearch,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus),
|
||||||
|
singleLine = true,
|
||||||
|
shape = CircleShape,
|
||||||
|
placeholder = { Text("Search library") },
|
||||||
|
trailingIcon = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearch("") }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A long press's menu, from the bottom of the screen where the thumb already
|
* A long press's menu, from the bottom of the screen where the thumb already
|
||||||
* is. Each row closes it before doing its thing.
|
* is. Each row closes it before doing its thing.
|
||||||
@@ -238,7 +455,21 @@ private fun LibraryGrid(
|
|||||||
onTitle: (TitleRecord) -> Unit,
|
onTitle: (TitleRecord) -> Unit,
|
||||||
onTitleMenu: (TitleRecord) -> Unit,
|
onTitleMenu: (TitleRecord) -> Unit,
|
||||||
onResumeMenu: (Resumable) -> Unit,
|
onResumeMenu: (Resumable) -> Unit,
|
||||||
|
onFilter: (LibraryFilter) -> Unit = {},
|
||||||
|
metadataOffer: MetadataOffer? = null,
|
||||||
) {
|
) {
|
||||||
|
// The arrangement names titles by key; before it has arrived, the titles
|
||||||
|
// as they came are the grid.
|
||||||
|
val byKey = remember(state.titles) { state.titles.associateBy { it.key } }
|
||||||
|
val tiles = remember(state.titles, state.arrangement) {
|
||||||
|
state.arrangement?.tiles?.mapNotNull { tile -> byKey[tile.key]?.let { Tile(it, tile.folded.toInt()) } }
|
||||||
|
?: state.titles.map { Tile(it, 0) }
|
||||||
|
}
|
||||||
|
val shelves = remember(state.titles, state.arrangement) {
|
||||||
|
state.arrangement?.shelves.orEmpty()
|
||||||
|
.map { shelf -> Shelf(shelf.label, shelf.keys.mapNotNull(byKey::get)) }
|
||||||
|
.filter { it.titles.isNotEmpty() }
|
||||||
|
}
|
||||||
// Posters, three across on a phone. Cropped backdrops at full width fitted
|
// Posters, three across on a phone. Cropped backdrops at full width fitted
|
||||||
// two titles to a screen, which is a library read one row at a time.
|
// two titles to a screen, which is a library read one row at a time.
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
@@ -252,6 +483,9 @@ private fun LibraryGrid(
|
|||||||
// opening the app wants is almost always the thing they were part way
|
// opening the app wants is almost always the thing they were part way
|
||||||
// through. Inside the grid rather than pinned over it, so the shelf
|
// through. Inside the grid rather than pinned over it, so the shelf
|
||||||
// scrolls away with the rest.
|
// scrolls away with the rest.
|
||||||
|
if (metadataOffer != null && state.query.isBlank()) {
|
||||||
|
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
|
||||||
|
}
|
||||||
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||||
featured?.let { title ->
|
featured?.let { title ->
|
||||||
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
@@ -281,16 +515,101 @@ private fun LibraryGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (state.query.isBlank()) {
|
||||||
|
// 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)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(shelf.titles, key = { it.key }) { title ->
|
||||||
|
PosterTile(
|
||||||
|
title,
|
||||||
|
onClick = { onTitle(title) },
|
||||||
|
onLongClick = { onTitleMenu(title) },
|
||||||
|
modifier = Modifier.width(SHELF_TILE_WIDTH),
|
||||||
|
shared = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
Text("Library", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(state.titles, key = { it.key }) { title ->
|
items(tiles, key = { it.title.key }) { tile ->
|
||||||
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) })
|
PosterTile(
|
||||||
|
tile.title,
|
||||||
|
onClick = { onTitle(tile.title) },
|
||||||
|
onLongClick = { onTitleMenu(tile.title) },
|
||||||
|
folded = tile.folded,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's shape before the catalog has answered: posters and their two
|
||||||
|
* lines of text as blocks, so the page does not jump when the titles land.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LibrarySkeleton() {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(108.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||||
|
userScrollEnabled = false,
|
||||||
|
) {
|
||||||
|
items(SKELETON_TILES) {
|
||||||
|
Column {
|
||||||
|
Skeleton(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Skeleton(Modifier.fillMaxWidth(0.8f).height(12.dp).clip(MaterialTheme.shapes.small))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Skeleton(Modifier.fillMaxWidth(0.5f).height(10.dp).clip(MaterialTheme.shapes.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shelf's covers: a little narrower than the grid's, so the row reads as a row. */
|
||||||
|
private val SHELF_TILE_WIDTH = 112.dp
|
||||||
|
|
||||||
|
/** Enough placeholder tiles to fill a tablet's first screen. */
|
||||||
|
private const val SKELETON_TILES = 18
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The title the banner shows: the one watched last, or with nothing part
|
* The title the banner shows: the one watched last, or with nothing part
|
||||||
* watched, one of the titles the provider has a backdrop for, turning over
|
* watched, one of the titles the provider has a backdrop for, turning over
|
||||||
@@ -310,9 +629,10 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
|||||||
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
title.backdropUrl ?: title.posterUrl,
|
title.backdropUrl ?: title.posterUrl,
|
||||||
title.canonicalName ?: title.name,
|
title.displayName,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = title.thumbnailSource,
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
@@ -321,7 +641,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
|||||||
)
|
)
|
||||||
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
title.canonicalName ?: title.name,
|
title.displayName,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -346,7 +666,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||||
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
|
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
|
||||||
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(if (resume != null) "Resume" else "Play")
|
Text(if (resume != null) "Resume" else "Play")
|
||||||
@@ -364,14 +684,16 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
|||||||
Modifier
|
Modifier
|
||||||
.width(220.dp)
|
.width(220.dp)
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this episode"),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||||
|
// Named by the text under it; read as well, the name came twice.
|
||||||
RemoteArtwork(
|
RemoteArtwork(
|
||||||
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
entry.title.canonicalName ?: entry.title.name,
|
entry.title.displayName,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize().clearAndSetSemantics {},
|
||||||
fallback = entry.episode.thumbnailSource,
|
fallback = entry.episode.thumbnailSource,
|
||||||
|
maxEdge = 220.dp,
|
||||||
)
|
)
|
||||||
entry.episode.progress?.let { progress ->
|
entry.episode.progress?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -381,14 +703,18 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
entry.title.canonicalName ?: entry.title.name,
|
entry.title.displayName,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
if (entry.episode.resumeAt == null) {
|
||||||
|
"Next: ${entry.episode.label}"
|
||||||
|
} else {
|
||||||
|
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · ")
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -397,23 +723,59 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One title: its poster, and its name and size under it rather than in a card. */
|
/**
|
||||||
|
* One title: its poster, and its name and size under it rather than in a card.
|
||||||
|
*
|
||||||
|
* [folded] is how many more titles of its franchise the tile stands for, said
|
||||||
|
* in the corner. [shared] is whether the poster grows into the title page —
|
||||||
|
* only one copy of a title on screen can, and a shelf may hold the same title
|
||||||
|
* as the grid.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: () -> Unit) {
|
private fun PosterTile(
|
||||||
|
title: TitleRecord,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
folded: Int = 0,
|
||||||
|
shared: Boolean = true,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
modifier
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
|
||||||
) {
|
) {
|
||||||
RemoteArtwork(
|
Box {
|
||||||
title.posterUrl ?: title.backdropUrl,
|
RemoteArtwork(
|
||||||
title.canonicalName ?: title.name,
|
title.posterUrl ?: title.backdropUrl,
|
||||||
Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
|
title.displayName,
|
||||||
fallback = title.thumbnailSource,
|
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||||
)
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.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(
|
||||||
|
"+$folded",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(Color.Black.copy(alpha = 0.7f))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
.semantics { contentDescription = "and $folded more" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
title.canonicalName ?: title.name,
|
title.displayName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -17,16 +32,18 @@ import androidx.compose.foundation.layout.aspectRatio
|
|||||||
import androidx.compose.foundation.layout.calculateEndPadding
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
import androidx.compose.foundation.layout.calculateStartPadding
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.plus
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -35,7 +52,6 @@ import androidx.compose.material.icons.filled.Share
|
|||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -60,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import io.narl.protonstream.ui.theme.accentNavigationColors
|
import io.narl.protonstream.ui.theme.accentNavigationColors
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
@@ -68,6 +85,9 @@ import io.narl.protonstream.playback.LibmpvHost
|
|||||||
import io.narl.protonstream.playback.NativeMpvHost
|
import io.narl.protonstream.playback.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlayerScreen
|
import io.narl.protonstream.playback.PlayerScreen
|
||||||
|
|
||||||
|
/** From this width the library and an open title share the window. */
|
||||||
|
private val TWO_PANE_WIDTH = 840.dp
|
||||||
|
|
||||||
/** How much room the floating mini transport needs at the foot of a page. */
|
/** How much room the floating mini transport needs at the foot of a page. */
|
||||||
private val MINI_TRANSPORT_INSET = 88.dp
|
private val MINI_TRANSPORT_INSET = 88.dp
|
||||||
|
|
||||||
@@ -86,11 +106,10 @@ private enum class Destination(val label: String, val icon: ImageVector) {
|
|||||||
Settings("Settings", Icons.Default.Settings),
|
Settings("Settings", Icons.Default.Settings),
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProtonStreamApp(
|
fun ProtonStreamApp(
|
||||||
playerHost: LibmpvHost? = null,
|
playerHost: LibmpvHost? = null,
|
||||||
inPictureInPicture: Boolean = false,
|
|
||||||
shareLink: String? = null,
|
shareLink: String? = null,
|
||||||
onShareLinkTaken: () -> Unit = {},
|
onShareLinkTaken: () -> Unit = {},
|
||||||
model: AppViewModel = viewModel(
|
model: AppViewModel = viewModel(
|
||||||
@@ -115,6 +134,7 @@ fun ProtonStreamApp(
|
|||||||
// is what says so. Saved, because a rotation must not throw the viewer back
|
// is what says so. Saved, because a rotation must not throw the viewer back
|
||||||
// into the video they had just left.
|
// into the video they had just left.
|
||||||
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
var playerMinimized by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var addingShare by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
val selectedTitle = state.titles.firstOrNull { it.key == selectedTitleKey }
|
||||||
val playing = playingTitleKey
|
val playing = playingTitleKey
|
||||||
@@ -162,23 +182,10 @@ fun ProtonStreamApp(
|
|||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbars) },
|
snackbarHost = { SnackbarHost(snackbars) },
|
||||||
topBar = {
|
// Each page draws its own bar under the status bar, and the title
|
||||||
TopAppBar(
|
// page draws its backdrop there, so the top inset is theirs.
|
||||||
title = { Text(selectedTitle?.name ?: "proton-stream") },
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets
|
||||||
actions = {
|
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom),
|
||||||
if (destination == Destination.Library) {
|
|
||||||
if (state.refreshing) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
} else {
|
|
||||||
IconButton(onClick = model::refresh) {
|
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// Back from a secondary tab returns to the library rather than
|
// Back from a secondary tab returns to the library rather than
|
||||||
// leaving the app; the library's own back is the system's, which is
|
// leaving the app; the library's own back is the system's, which is
|
||||||
@@ -200,46 +207,110 @@ fun ProtonStreamApp(
|
|||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AnimatedContent(destination, label = "primary navigation") { target ->
|
AnimatedContent(destination, label = "primary navigation") { target ->
|
||||||
when (target) {
|
when (target) {
|
||||||
Destination.Library -> if (selectedTitle == null) {
|
// Side by side where there is room for both: on a tablet the
|
||||||
LibraryScreen(
|
// library stays in view while a title is open, so moving
|
||||||
state,
|
// between titles is one tap rather than back and in again.
|
||||||
model::search,
|
Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
{ title, index ->
|
val libraryPane = @Composable {
|
||||||
playingTitleKey = title.key
|
LibraryScreen(
|
||||||
playingIndex = index
|
state,
|
||||||
playerMinimized = false
|
model::search,
|
||||||
},
|
{ title, index ->
|
||||||
{ selectedTitleKey = it.key },
|
playingTitleKey = title.key
|
||||||
model::refresh,
|
playingIndex = index
|
||||||
model::setTitleWatched,
|
playerMinimized = false
|
||||||
model::forgetPosition,
|
},
|
||||||
body,
|
{ selectedTitleKey = it.key },
|
||||||
)
|
model::refresh,
|
||||||
} else {
|
model::setTitleWatched,
|
||||||
TitleScreen(
|
model::forgetPosition,
|
||||||
selectedTitle,
|
body,
|
||||||
playerHost != null,
|
onMatchChanged = model::reloadAfterMetadataChange,
|
||||||
{ _, index ->
|
onError = model::reportError,
|
||||||
playingTitleKey = selectedTitle.key
|
onArrange = model::arrange,
|
||||||
playingIndex = index
|
onAddShare = {
|
||||||
playerMinimized = false
|
destination = Destination.Shares
|
||||||
},
|
addingShare = true
|
||||||
{ selectedTitleKey = null },
|
},
|
||||||
model::reportError,
|
onOpenSettings = { destination = Destination.Settings },
|
||||||
model::reloadAfterMetadataChange,
|
onFilter = model::filter,
|
||||||
model::setWatched,
|
)
|
||||||
body,
|
}
|
||||||
)
|
val titlePane = @Composable { selected: TitleRecord ->
|
||||||
|
TitleScreen(
|
||||||
|
selected,
|
||||||
|
playerHost != null,
|
||||||
|
{ _, index ->
|
||||||
|
playingTitleKey = selected.key
|
||||||
|
playingIndex = index
|
||||||
|
playerMinimized = false
|
||||||
|
},
|
||||||
|
{ selectedTitleKey = null },
|
||||||
|
model::reportError,
|
||||||
|
model::reloadAfterMetadataChange,
|
||||||
|
model::setWatched,
|
||||||
|
body,
|
||||||
|
onMarkEpisodes = model::markEpisodes,
|
||||||
|
franchise = selected.franchise.mapNotNull { key ->
|
||||||
|
state.titles.firstOrNull { it.key == key }
|
||||||
|
},
|
||||||
|
onOpenTitle = { selectedTitleKey = it.key },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (maxWidth >= TWO_PANE_WIDTH) {
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
Box(Modifier.weight(0.45f)) { libraryPane() }
|
||||||
|
VerticalDivider()
|
||||||
|
Box(Modifier.weight(0.55f)) {
|
||||||
|
selectedTitle?.let { titlePane(it) } ?: Box(Modifier.padding(body)) {
|
||||||
|
EmptyState("Choose a title", "Its seasons and episodes open here.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SharedTransitionLayout {
|
||||||
|
AnimatedContent(selectedTitle, contentKey = { it?.key }, label = "title") { open ->
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSharedTransition provides this@SharedTransitionLayout,
|
||||||
|
LocalPaneVisibility provides this,
|
||||||
|
) {
|
||||||
|
open?.let { titlePane(it) } ?: libraryPane()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Destination.Shares -> SharesScreen(
|
Destination.Shares -> SharesScreen(
|
||||||
state.shares,
|
state.shares,
|
||||||
model::addShare,
|
{ name, url, password ->
|
||||||
|
// The first share is the library: watch it fill
|
||||||
|
// rather than stay on a form that has done its job.
|
||||||
|
val first = state.titles.isEmpty()
|
||||||
|
model.addShare(name, url, password).also { refusal ->
|
||||||
|
if (refusal == null && first) destination = Destination.Library
|
||||||
|
}
|
||||||
|
},
|
||||||
model::repairShare,
|
model::repairShare,
|
||||||
model::refreshShare,
|
model::refreshShare,
|
||||||
model::removeShare,
|
model::removeShare,
|
||||||
shareLink,
|
// The empty library's button opens the form as a
|
||||||
onShareLinkTaken,
|
// pasted link would, only with nothing in it.
|
||||||
|
shareLink ?: "".takeIf { addingShare },
|
||||||
|
{ addingShare = false; onShareLinkTaken() },
|
||||||
body,
|
body,
|
||||||
|
account = state.account,
|
||||||
|
accountActions = AccountActions(
|
||||||
|
signIn = model::signIn,
|
||||||
|
submitSecondFactor = model::submitSecondFactor,
|
||||||
|
submitMailboxPassword = model::submitMailboxPassword,
|
||||||
|
cancelSignIn = model::cancelSignIn,
|
||||||
|
dismissVerification = model::dismissVerification,
|
||||||
|
signOut = model::signOut,
|
||||||
|
syncNow = model::syncNow,
|
||||||
|
addFolder = model::addAccountFolder,
|
||||||
|
onError = model::reportError,
|
||||||
|
),
|
||||||
|
onRename = model::renameShare,
|
||||||
)
|
)
|
||||||
Destination.History -> HistoryScreen(
|
Destination.History -> HistoryScreen(
|
||||||
state,
|
state,
|
||||||
@@ -259,6 +330,15 @@ fun ProtonStreamApp(
|
|||||||
model::resumeDownload,
|
model::resumeDownload,
|
||||||
model::deletePartial,
|
model::deletePartial,
|
||||||
body,
|
body,
|
||||||
|
onPlay = { title, index ->
|
||||||
|
playingTitleKey = title.key
|
||||||
|
playingIndex = index
|
||||||
|
playerMinimized = false
|
||||||
|
},
|
||||||
|
onOpen = { title ->
|
||||||
|
destination = Destination.Library
|
||||||
|
selectedTitleKey = title.key
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Destination.Settings -> SettingsScreen(
|
Destination.Settings -> SettingsScreen(
|
||||||
state,
|
state,
|
||||||
@@ -267,6 +347,7 @@ fun ProtonStreamApp(
|
|||||||
model::clearBlockCache,
|
model::clearBlockCache,
|
||||||
model::removeAllOffline,
|
model::removeAllOffline,
|
||||||
body,
|
body,
|
||||||
|
onNamesChanged = model::reloadAfterMetadataChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,29 +358,35 @@ fun ProtonStreamApp(
|
|||||||
onClose = {
|
onClose = {
|
||||||
playingTitleKey = null
|
playingTitleKey = null
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
model.reloadAfterMetadataChange()
|
model.playerClosed()
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
.padding(bottom = padding.calculateBottomPadding() + 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ActivityBanner(
|
||||||
|
state.activity,
|
||||||
|
onStop = model::stopCrawl.takeIf { state.crawling },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = body.calculateBottomPadding() + 8.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawn over the scaffold rather than instead of it. The player owns the
|
// Drawn over the scaffold rather than instead of it. The player owns the
|
||||||
// whole window while it is up — it is a screen, not a dialog over one, which
|
// whole window while it is up — it is a screen, not a dialog over one, which
|
||||||
// is what lets it draw into the cutout and hand the right window to
|
// is what lets it draw into the cutout — but the page behind it stays
|
||||||
// Picture-in-Picture — but the page behind it stays composed, so leaving the
|
// composed, so leaving the player finds the title page where it was rather
|
||||||
// player finds the title page where it was rather than rebuilt from the top.
|
// than rebuilt from the top.
|
||||||
playing?.let { request ->
|
playing?.let { request ->
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
episodes = request.episodes,
|
episodes = request.episodes,
|
||||||
index = request.index,
|
index = request.index,
|
||||||
host = playerHost,
|
host = playerHost,
|
||||||
inPictureInPicture = inPictureInPicture,
|
|
||||||
minimized = playerMinimized,
|
minimized = playerMinimized,
|
||||||
onIndexChange = { playingIndex = it },
|
onIndexChange = { playingIndex = it },
|
||||||
onSaveProgress = model::saveProgress,
|
onSaveProgress = model::saveProgress,
|
||||||
@@ -307,7 +394,7 @@ fun ProtonStreamApp(
|
|||||||
onClose = {
|
onClose = {
|
||||||
playingTitleKey = null
|
playingTitleKey = null
|
||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
model.reloadAfterMetadataChange()
|
model.playerClosed()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -357,3 +444,37 @@ private fun MiniTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the app is busy with, for as long as it is: a spinner and one line.
|
||||||
|
* Not dismissible and not timed — it goes when the work does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = activity != null,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = fadeIn() + slideInVertically { it },
|
||||||
|
exit = fadeOut() + slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
// Held through the exit, so the line does not blank before it fades.
|
||||||
|
var shown by remember { mutableStateOf(activity.orEmpty()) }
|
||||||
|
if (activity != null) shown = activity
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
|
||||||
|
// A crawl of a large share can take minutes, and is the viewer's
|
||||||
|
// to call off; what it has read so far stays.
|
||||||
|
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
|
|||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,12 +21,20 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import 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 io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -64,7 +77,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
|||||||
*
|
*
|
||||||
* [fallback] is used only when there is no provider artwork or it fails to
|
* [fallback] is used only when there is no provider artwork or it fails to
|
||||||
* load, which — with metadata off — is the common case rather than the rare
|
* load, which — with metadata off — is the common case rather than the rare
|
||||||
* one.
|
* one. Until either answers, the box pulses as a skeleton; when neither has
|
||||||
|
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||||
|
* initials when [labelled] is false, for art with the name already over it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RemoteArtwork(
|
internal fun RemoteArtwork(
|
||||||
@@ -72,19 +87,102 @@ internal fun RemoteArtwork(
|
|||||||
description: String,
|
description: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
|
labelled: Boolean = true,
|
||||||
|
maxEdge: Dp? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
val enabled = LocalArtworkLoading.current
|
||||||
LaunchedEffect(url, fallback) {
|
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
// 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, edge) ?: loadThumbnail(fallback, edge))
|
||||||
|
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||||
|
?: ArtworkLoad.Missing
|
||||||
}
|
}
|
||||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
when (val loaded = image) {
|
||||||
image?.let {
|
ArtworkLoad.Pending -> Skeleton(modifier)
|
||||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
||||||
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed interface ArtworkLoad {
|
||||||
|
data object Pending : ArtworkLoad
|
||||||
|
data object Missing : ArtworkLoad
|
||||||
|
data class Loaded(val image: ImageBitmap) : ArtworkLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [RemoteArtwork] fetches anything at all.
|
||||||
|
*
|
||||||
|
* Off in the screenshot tests: there is no bridge on the host, and whether a
|
||||||
|
* failed fetch lands before or after the capture would decide between a
|
||||||
|
* skeleton and a placeholder in the committed image.
|
||||||
|
*/
|
||||||
|
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something being loaded: the surface tint, breathing.
|
||||||
|
*
|
||||||
|
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||||
|
* says what is coming and where, and settles the layout before it arrives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||||
|
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||||
|
initialValue = 0.55f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||||
|
label = "skeleton alpha",
|
||||||
|
)
|
||||||
|
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art for a title that has none: the accent as a gradient, and its initials.
|
||||||
|
*
|
||||||
|
* A black card with a play triangle on it said nothing about which title it
|
||||||
|
* was, so a grid of unmatched titles was a grid of identical cards. Each name
|
||||||
|
* sets where on the accent ramp its gradient starts, so neighbours differ
|
||||||
|
* while every one stays in the theme's colours.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val brush = remember(name, scheme.primary, scheme.secondary) {
|
||||||
|
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
|
||||||
|
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
|
||||||
|
if (!labelled) return@BoxWithConstraints
|
||||||
|
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
|
||||||
|
Text(
|
||||||
|
initials(name),
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
fontSize = size,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
||||||
|
* capitalised words, so particles and articles in a romanised name do not
|
||||||
|
* take a slot. A name with no capitals uses its first two words.
|
||||||
|
*/
|
||||||
|
internal fun initials(name: String): String {
|
||||||
|
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
|
||||||
|
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
|
||||||
|
return picked.take(2).joinToString("") { it.first().uppercase() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||||
*
|
*
|
||||||
@@ -93,8 +191,8 @@ internal fun RemoteArtwork(
|
|||||||
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||||
* shows costs no second download.
|
* shows costs no second download.
|
||||||
*/
|
*/
|
||||||
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||||
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val uri = URI(requested)
|
val uri = URI(requested)
|
||||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||||
@@ -104,7 +202,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
|||||||
val cached = directory.resolve("$digest.img")
|
val cached = directory.resolve("$digest.img")
|
||||||
val fresh = !cached.isFile
|
val fresh = !cached.isFile
|
||||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||||
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
|
val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" }
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
// Written only once it has decoded. Caching the bytes first is
|
// Written only once it has decoded. Caching the bytes first is
|
||||||
// what turns one hostile poster into a crash on every launch
|
// what turns one hostile poster into a crash on every launch
|
||||||
@@ -117,7 +215,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
|||||||
cached.setLastModified(System.currentTimeMillis())
|
cached.setLastModified(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
bitmap
|
bitmap
|
||||||
}.getOrNull()?.also { decoded.put(requested, it) }
|
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,21 +229,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
|||||||
* share or a session that has to be reopened is transient, and the next
|
* share or a session that has to be reopened is transient, and the next
|
||||||
* composition should try again.
|
* composition should try again.
|
||||||
*/
|
*/
|
||||||
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
|
internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
|
||||||
val file = source ?: return null
|
val file = source ?: return null
|
||||||
val key = file.key
|
val key = file.key
|
||||||
decoded.get(key)?.let { return it }
|
decoded.get(sized(key, edge))?.let { return it }
|
||||||
if (key in missing) return null
|
if (key in missing) return null
|
||||||
val fetched = withContext(Dispatchers.IO) {
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||||
}
|
}
|
||||||
val bytes = fetched.getOrElse { return null }
|
val bytes = fetched.getOrElse { return null }
|
||||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
|
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
|
||||||
if (bitmap == null) {
|
if (bitmap == null) {
|
||||||
missing.add(key)
|
missing.add(key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
decoded.put(key, bitmap)
|
decoded.put(sized(key, edge), bitmap)
|
||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,28 +262,43 @@ private fun download(uri: URI): ByteArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode within a bound the display can actually use.
|
* Decode with its longest edge at most [edge] pixels.
|
||||||
*
|
*
|
||||||
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||||
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||||
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||||
* costs no allocation and settles a sample size before anything is committed.
|
* costs no allocation and settles a sample size before anything is committed.
|
||||||
|
*
|
||||||
|
* Sampling halves, so it stops while the image is still at least [edge]; the
|
||||||
|
* decoder scales the rest of the way, which a poster sampled straight to under
|
||||||
|
* its tile would show as blur.
|
||||||
*/
|
*/
|
||||||
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? {
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||||
if (longest <= 0) return null
|
if (longest <= 0) return null
|
||||||
var sample = 1
|
var sample = 1
|
||||||
while (longest / sample > MAX_ART_EDGE) sample *= 2
|
while (longest / (sample * 2) >= edge) sample *= 2
|
||||||
|
val sampled = longest / sample
|
||||||
return BitmapFactory.decodeByteArray(
|
return BitmapFactory.decodeByteArray(
|
||||||
bytes,
|
bytes,
|
||||||
0,
|
0,
|
||||||
bytes.size,
|
bytes.size,
|
||||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sample
|
||||||
|
if (sampled > edge) {
|
||||||
|
inScaled = true
|
||||||
|
inDensity = sampled
|
||||||
|
inTargetDensity = edge
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The decoded cache's key for one image at one size. */
|
||||||
|
private fun sized(key: String, edge: Int) = "$key#$edge"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||||
*
|
*
|
||||||
@@ -222,7 +335,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
|
|||||||
* is a full-resolution decode per item.
|
* is a full-resolution decode per item.
|
||||||
*/
|
*/
|
||||||
private val decoded = object : LruCache<String, Bitmap>(
|
private val decoded = object : LruCache<String, Bitmap>(
|
||||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
|
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
|
||||||
) {
|
) {
|
||||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircle
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -46,18 +59,21 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.compose.ui.window.SecureFlagPolicy
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import io.narl.protonstream.download.DownloadCoordinator
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.DangerButton
|
||||||
import io.narl.protonstream.ui.theme.AppearanceState
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
import io.narl.protonstream.ui.theme.EdgedButton
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
import uniffi.pstr_android.MetadataProvider
|
import uniffi.pstr_android.MetadataProvider
|
||||||
import uniffi.pstr_android.FlavorChoice
|
import uniffi.pstr_android.FlavorChoice
|
||||||
import uniffi.pstr_android.AppearanceRecord
|
import uniffi.pstr_android.AppearanceRecord
|
||||||
|
import uniffi.pstr_android.TitleNamesChoice
|
||||||
import uniffi.pstr_android.AccentChoice
|
import uniffi.pstr_android.AccentChoice
|
||||||
import uniffi.pstr_android.PlaybackPrefsRecord
|
import uniffi.pstr_android.PlaybackPrefsRecord
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
@@ -72,16 +88,24 @@ import io.narl.protonstream.ui.theme.AccentProgress
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SettingsScreen(
|
internal fun SettingsScreen(
|
||||||
state: AppUiState,
|
state: AppUiState,
|
||||||
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
/** Resolves to why the settings were not stored, or null once they are. */
|
||||||
|
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||||
onMatchAgain: () -> Unit,
|
onMatchAgain: () -> Unit,
|
||||||
onClearCache: () -> Unit,
|
onClearCache: () -> Unit,
|
||||||
onRemoveAllOffline: () -> Unit,
|
onRemoveAllOffline: () -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
initialPage: SettingsPage? = null,
|
||||||
|
// A parameter so the screenshot tests can pin it: read from the package,
|
||||||
|
// every version bump changed the committed image of this page.
|
||||||
|
version: String = appVersion(),
|
||||||
|
/** The library's names changed, and it has to be read again to show them. */
|
||||||
|
onNamesChanged: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settings = remember { SettingsStore(context) }
|
val settings = remember { SettingsStore(context) }
|
||||||
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
|
||||||
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||||
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||||
// Playback preferences are the shared store's, not this app's: they are the
|
// Playback preferences are the shared store's, not this app's: they are the
|
||||||
@@ -103,156 +127,194 @@ internal fun SettingsScreen(
|
|||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
var showMetadata by remember { mutableStateOf(false) }
|
var showMetadata by remember { mutableStateOf(false) }
|
||||||
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||||
// Rows with one line of explanation each, in groups, rather than a toggle
|
// One row per area on the first page, each opening its own page: grouped
|
||||||
// followed by a paragraph: the page read as a document to get through
|
// on one page, the storage controls sat a long scroll below playback, and
|
||||||
// before reaching the switch that was wanted.
|
// every setting added made the rest harder to find.
|
||||||
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) {
|
var page by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
SettingsGroup("Playback")
|
BackHandler(enabled = page != null) { page = null }
|
||||||
prefs?.let { current ->
|
val back: @Composable () -> Unit = {
|
||||||
SettingSwitch(
|
IconButton(onClick = { page = null }) {
|
||||||
"Autoplay next episode",
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
|
||||||
"Starts the next episode when the credits begin",
|
|
||||||
current.autoplayNext,
|
|
||||||
) { on -> update { it.copy(autoplayNext = on) } }
|
|
||||||
// Off is the safer default: a mis-named chapter then costs a tap on
|
|
||||||
// Skip rather than a scene.
|
|
||||||
SettingSwitch(
|
|
||||||
"Skip openings and endings",
|
|
||||||
"Uses the release's chapters; off shows a Skip button instead",
|
|
||||||
current.autoSkip,
|
|
||||||
) { on -> update { it.copy(autoSkip = on) } }
|
|
||||||
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
|
||||||
update { it.copy(subtitles = on) }
|
|
||||||
}
|
|
||||||
// Language tags, not a picker: which languages exist is a property
|
|
||||||
// of each file, and a list built from one episode is wrong for the
|
|
||||||
// next. A show that has been given its own choice keeps it.
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
LanguageField("Audio language", current.audioLanguage, Modifier.weight(1f)) { tag ->
|
|
||||||
update { it.copy(audioLanguage = tag) }
|
|
||||||
}
|
|
||||||
LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag ->
|
|
||||||
update { it.copy(subtitleLanguage = tag) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"Tags as they appear in the file, such as jpn or eng",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
SettingSwitch(
|
}
|
||||||
"Hardware decoding",
|
when (page) {
|
||||||
"Turn off if video shows green or torn frames",
|
null -> TabPage("Settings", padding) { body ->
|
||||||
hardwareDecoding,
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
) {
|
SettingsPage.entries.forEach { entry ->
|
||||||
hardwareDecoding = it
|
ListItem(
|
||||||
settings.hardwareDecoding = it
|
headlineContent = { Text(entry.title) },
|
||||||
}
|
supportingContent = {
|
||||||
SettingSwitch(
|
Text(
|
||||||
"Background audio",
|
when (entry) {
|
||||||
"Keeps playing with the app closed, from the notification",
|
SettingsPage.Playback -> "Autoplay, languages and decoding"
|
||||||
backgroundAudio,
|
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
|
||||||
) {
|
"${state.storage.offlineCount} offline"
|
||||||
backgroundAudio = it
|
SettingsPage.Appearance -> "Palette, accent and gradient"
|
||||||
settings.backgroundAudio = it
|
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
|
||||||
}
|
"On · ${state.metadataSettings.provider.displayName()}"
|
||||||
|
} else {
|
||||||
SettingsGroup("Downloads and storage")
|
"Off"
|
||||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
}
|
||||||
wifiOnly = it
|
SettingsPage.About -> "Version $version · licences"
|
||||||
settings.wifiOnly = it
|
},
|
||||||
// Constraints are baked in at enqueue time, so a queue that already
|
)
|
||||||
// exists keeps the policy it was queued under until it is re-issued.
|
|
||||||
DownloadCoordinator.applyNetworkPolicy(context)
|
|
||||||
}
|
|
||||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
|
||||||
Text("Streaming cache")
|
|
||||||
Text(
|
|
||||||
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
AccentProgress(
|
|
||||||
progress = {
|
|
||||||
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
|
|
||||||
.toFloat().coerceIn(0f, 1f)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
||||||
)
|
|
||||||
// What has been watched is kept up to this much, so going back over
|
|
||||||
// a scene costs no download. A lower choice frees the difference at
|
|
||||||
// once. Wrapping, so five chips survive a narrow phone at a large
|
|
||||||
// font scale.
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
|
|
||||||
FilterChip(
|
|
||||||
selected = gib == cacheBudgetGib,
|
|
||||||
onClick = {
|
|
||||||
cacheBudgetGib = gib
|
|
||||||
settings.cacheBudgetGib = gib
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
label = { Text("$gib GiB") },
|
leadingContent = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { page = entry },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The cache is rebuildable, so it goes without asking. Offline episodes
|
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
|
||||||
// are a choice the viewer made, so that one asks.
|
prefs?.let { current ->
|
||||||
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
|
SettingSwitch(
|
||||||
SettingAction(
|
"Autoplay next episode",
|
||||||
"Delete all offline episodes",
|
"Starts the next episode when the credits begin",
|
||||||
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
|
current.autoplayNext,
|
||||||
" · ${formatBytes(state.storage.offlineBytes)}" +
|
) { on -> update { it.copy(autoplayNext = on) } }
|
||||||
(
|
// Off is the safer default: a mis-named chapter then costs a tap on
|
||||||
state.storage.partialBytes.takeIf { it > 0uL }
|
// Skip rather than a scene.
|
||||||
?.let { " · ${formatBytes(it)} unfinished" }
|
SettingSwitch(
|
||||||
.orEmpty()
|
"Skip openings and endings",
|
||||||
),
|
"Uses the release's chapters; off shows a Skip button instead",
|
||||||
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
current.autoSkip,
|
||||||
onClick = { confirmDelete = true },
|
) { on -> update { it.copy(autoSkip = on) } }
|
||||||
)
|
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
||||||
|
update { it.copy(subtitles = on) }
|
||||||
SettingsGroup("Appearance")
|
}
|
||||||
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
|
// Preferences, not filters: a file with nothing in the language
|
||||||
|
// plays its own default track. A show that has been given its
|
||||||
SettingsGroup("Metadata")
|
// own choice keeps it.
|
||||||
SettingAction(
|
LanguageSetting("Audio language", current.audioLanguage) { tag ->
|
||||||
"Metadata enrichment",
|
update { it.copy(audioLanguage = tag) }
|
||||||
if (state.metadataSettings.enabled) {
|
}
|
||||||
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
|
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
|
||||||
} else {
|
update { it.copy(subtitleLanguage = tag) }
|
||||||
"Off, the privacy default"
|
}
|
||||||
},
|
}
|
||||||
onClick = { showMetadata = true },
|
SettingSwitch(
|
||||||
)
|
"Hardware decoding",
|
||||||
// Every title looked up again, matched ones included: the way out of a
|
"Turn off if video shows green or torn frames",
|
||||||
// library the provider answered wrong, which otherwise stays wrong for
|
hardwareDecoding,
|
||||||
// as long as the match is remembered.
|
) {
|
||||||
SettingAction(
|
hardwareDecoding = it
|
||||||
"Match everything again",
|
settings.hardwareDecoding = it
|
||||||
"Looks every title up again, hand-picked matches excepted",
|
}
|
||||||
enabled = state.metadataSettings.enabled && !state.refreshing,
|
SettingSwitch(
|
||||||
onClick = onMatchAgain,
|
"Background audio",
|
||||||
)
|
"Keeps playing with the app closed, from the notification",
|
||||||
|
backgroundAudio,
|
||||||
SettingsGroup("About")
|
) {
|
||||||
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
backgroundAudio = it
|
||||||
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||||
|
wifiOnly = it
|
||||||
|
settings.wifiOnly = it
|
||||||
|
// Constraints are baked in at enqueue time, so a queue that already
|
||||||
|
// exists keeps the policy it was queued under until it is re-issued.
|
||||||
|
DownloadCoordinator.applyNetworkPolicy(context)
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Text("Streaming cache")
|
||||||
|
Text(
|
||||||
|
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
AccentProgress(
|
||||||
|
progress = {
|
||||||
|
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
|
||||||
|
.toFloat().coerceIn(0f, 1f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
// What has been watched is kept up to this much, so going back over
|
||||||
|
// a scene costs no download. A lower choice frees the difference at
|
||||||
|
// once. Wrapping, so five chips survive a narrow phone at a large
|
||||||
|
// font scale.
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
|
||||||
|
FilterChip(
|
||||||
|
selected = gib == cacheBudgetGib,
|
||||||
|
onClick = {
|
||||||
|
cacheBudgetGib = gib
|
||||||
|
settings.cacheBudgetGib = gib
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("$gib GiB") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The cache is rebuildable, so it goes without asking. Offline episodes
|
||||||
|
// are a choice the viewer made, so that one asks.
|
||||||
|
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
|
||||||
|
SettingAction(
|
||||||
|
"Delete all offline episodes",
|
||||||
|
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
|
||||||
|
" · ${formatBytes(state.storage.offlineBytes)}" +
|
||||||
|
(
|
||||||
|
state.storage.partialBytes.takeIf { it > 0uL }
|
||||||
|
?.let { " · ${formatBytes(it)} unfinished" }
|
||||||
|
.orEmpty()
|
||||||
|
),
|
||||||
|
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) }
|
||||||
|
}
|
||||||
|
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
|
||||||
|
SettingAction(
|
||||||
|
"Metadata enrichment",
|
||||||
|
if (state.metadataSettings.enabled) {
|
||||||
|
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
|
||||||
|
} else {
|
||||||
|
"Off, the privacy default"
|
||||||
|
},
|
||||||
|
onClick = { showMetadata = true },
|
||||||
|
)
|
||||||
|
// Every title looked up again, matched ones included: the way out of a
|
||||||
|
// library the provider answered wrong, which otherwise stays wrong for
|
||||||
|
// as long as the match is remembered.
|
||||||
|
SettingAction(
|
||||||
|
"Match everything again",
|
||||||
|
"Looks every title up again, hand-picked matches excepted",
|
||||||
|
enabled = state.metadataSettings.enabled && !state.refreshing,
|
||||||
|
onClick = onMatchAgain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Proton Stream") },
|
||||||
|
supportingContent = { Text("Version $version") },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
||||||
|
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
||||||
|
}
|
||||||
|
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
||||||
|
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -266,7 +328,7 @@ internal fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
@@ -278,23 +340,44 @@ internal fun SettingsScreen(
|
|||||||
MetadataSettingsDialog(
|
MetadataSettingsDialog(
|
||||||
current = state.metadataSettings,
|
current = state.metadataSettings,
|
||||||
onDismiss = { showMetadata = false },
|
onDismiss = { showMetadata = false },
|
||||||
onSave = { enabled, provider, language, key ->
|
onSave = onSaveMetadata,
|
||||||
onSaveMetadata(enabled, provider, language, key)
|
|
||||||
showMetadata = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A group's heading, in the accent, the way Android's own settings mark them. */
|
/** The areas the first page lists, each a page of its own. */
|
||||||
|
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
|
||||||
|
Playback("Playback", Icons.Default.PlayCircle),
|
||||||
|
Storage("Downloads and storage", Icons.Default.Storage),
|
||||||
|
Appearance("Appearance", Icons.Default.Palette),
|
||||||
|
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
|
||||||
|
About("About", Icons.Default.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One area's page: its name, a way back, and its rows. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsGroup(label: String) {
|
private fun SettingsSubPage(
|
||||||
Text(
|
page: SettingsPage,
|
||||||
label,
|
padding: PaddingValues,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
back: @Composable () -> Unit,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
rows: @Composable ColumnScope.() -> Unit,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 4.dp),
|
) {
|
||||||
)
|
TabPage(page.title, padding, navigationIcon = back) { body ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
|
rows()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installed version, as the launcher and the store show it. */
|
||||||
|
@Composable
|
||||||
|
private fun appVersion(): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember {
|
||||||
|
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
||||||
|
.getOrNull() ?: "unknown"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A switch with its one line of explanation; the whole row toggles it. */
|
/** A switch with its one line of explanation; the whole row toggles it. */
|
||||||
@@ -338,19 +421,24 @@ private fun SettingAction(
|
|||||||
private fun MetadataSettingsDialog(
|
private fun MetadataSettingsDialog(
|
||||||
current: uniffi.pstr_android.MetadataSettingsRecord,
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
onSave: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||||
) {
|
) {
|
||||||
var enabled by remember { mutableStateOf(current.enabled) }
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
var provider by remember { mutableStateOf(current.provider) }
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
var language by remember { mutableStateOf(current.language) }
|
var language by remember { mutableStateOf(current.language) }
|
||||||
var apiKey by remember { mutableStateOf("") }
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
|
||||||
|
val forgets = current.enabled && (!enabled || provider != current.provider)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!saving) onDismiss() },
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
title = { Text("Metadata enrichment") },
|
title = { Text("Metadata enrichment") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.")
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
|
||||||
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
MetadataProvider.entries.forEach { option ->
|
MetadataProvider.entries.forEach { option ->
|
||||||
@@ -374,15 +462,35 @@ private fun MetadataSettingsDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||||
|
// Said before Save rather than after: what is forgotten
|
||||||
|
// includes matches chosen by hand, which no look-up redoes.
|
||||||
|
if (forgets) {
|
||||||
|
Text(
|
||||||
|
"Saving forgets every poster, description and match stored now, " +
|
||||||
|
"including ones you chose by hand.",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(
|
AccentButton(
|
||||||
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
enabled = !needsKey && !saving,
|
||||||
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
onClick = {
|
||||||
) { Text("Save") }
|
saving = true
|
||||||
|
refusal = null
|
||||||
|
scope.launch {
|
||||||
|
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
|
||||||
|
saving = false
|
||||||
|
if (refusal == null) onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(if (saving) "Saving…" else "Save") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +540,7 @@ internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean)
|
|||||||
* the file both clients read.
|
* the file both clients read.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppearancePicker() {
|
private fun AppearancePicker(onNamesChanged: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
|
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
|
||||||
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
|
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
|
||||||
@@ -501,6 +609,26 @@ private fun AppearancePicker() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Text("Title names", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
items(TitleNamesChoice.entries.toList(), key = { it.name }) { names ->
|
||||||
|
if (names == current.names) {
|
||||||
|
AccentButton(onClick = {}) { Text(names.label()) }
|
||||||
|
} else {
|
||||||
|
EdgedButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
repaint(current.copy(names = names), store = true)
|
||||||
|
onNamesChanged()
|
||||||
|
}
|
||||||
|
}) { Text(names.label()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"What a matched title is called. Its other names still find it in search.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
|
SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
|
||||||
scope.launch { repaint(current.copy(gradients = on), store = true) }
|
scope.launch { repaint(current.copy(gradients = on), store = true) }
|
||||||
}
|
}
|
||||||
@@ -512,6 +640,13 @@ private fun AppearancePicker() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What each name setting is called. The desktop client says the same. */
|
||||||
|
private fun TitleNamesChoice.label() = when (this) {
|
||||||
|
TitleNamesChoice.LIBRARY -> "As in the share"
|
||||||
|
TitleNamesChoice.ENGLISH -> "English"
|
||||||
|
TitleNamesChoice.ROMAJI -> "Romaji"
|
||||||
|
}
|
||||||
|
|
||||||
/** What each palette family is called. The desktop client says the same. */
|
/** What each palette family is called. The desktop client says the same. */
|
||||||
private fun FlavorChoice.label() = when (this) {
|
private fun FlavorChoice.label() = when (this) {
|
||||||
FlavorChoice.PROTON -> "Proton"
|
FlavorChoice.PROTON -> "Proton"
|
||||||
@@ -521,20 +656,3 @@ private fun FlavorChoice.label() = when (this) {
|
|||||||
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
||||||
FlavorChoice.PERSONA5 -> "Persona 5"
|
FlavorChoice.PERSONA5 -> "Persona 5"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One language tag, committed as it is typed.
|
|
||||||
*
|
|
||||||
* Blank is a real answer and means "no preference" — the bridge stores it as
|
|
||||||
* absent, which is what leaves the choice to the container's own default track.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LanguageField(label: String, value: String?, modifier: Modifier = Modifier, onChange: (String?) -> Unit) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = value.orEmpty(),
|
|
||||||
onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) },
|
|
||||||
label = { Text(label) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -25,13 +30,18 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.window.SecureFlagPolicy
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.DangerButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import uniffi.pstr_android.AccountState
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
@@ -44,54 +54,79 @@ import androidx.compose.material.icons.filled.MoreVert
|
|||||||
import androidx.compose.material.icons.filled.Key
|
import androidx.compose.material.icons.filled.Key
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.FolderShared
|
import androidx.compose.material.icons.filled.FolderShared
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SharesScreen(
|
internal fun SharesScreen(
|
||||||
shares: List<ShareRecord>,
|
shares: List<ShareRecord>,
|
||||||
onAdd: (String, String, String?) -> Unit,
|
/** Resolves to why the link was refused, or null once it is added. */
|
||||||
|
onAdd: suspend (String, String, String?) -> String?,
|
||||||
onRepair: (String, String, String?) -> Unit,
|
onRepair: (String, String, String?) -> Unit,
|
||||||
onRefresh: (String) -> Unit,
|
onRefresh: (String) -> Unit,
|
||||||
onRemove: (String) -> Unit,
|
onRemove: (String) -> Unit,
|
||||||
incomingLink: String?,
|
incomingLink: String?,
|
||||||
onIncomingLinkTaken: () -> Unit,
|
onIncomingLinkTaken: () -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
account: AccountUiState = AccountUiState(),
|
||||||
|
accountActions: AccountActions? = null,
|
||||||
|
onRename: (String, String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
var signingIn by remember { mutableStateOf(false) }
|
||||||
|
var browsing by remember { mutableStateOf(false) }
|
||||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
TabPage("Shares", padding) { body ->
|
||||||
if (shares.isEmpty()) {
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
if (shares.isEmpty() && account.state == null) {
|
||||||
} else {
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
} else {
|
||||||
item(key = "heading") {
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
Text(
|
if (accountActions != null) {
|
||||||
"Proton Drive public links",
|
item(key = "account") {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
AccountCard(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
account,
|
||||||
)
|
accountActions,
|
||||||
}
|
onSignIn = { signingIn = true },
|
||||||
items(shares, key = { it.id }) { share ->
|
onBrowse = { browsing = true },
|
||||||
ShareRow(
|
)
|
||||||
share = share,
|
}
|
||||||
onRefresh = { onRefresh(share.id) },
|
}
|
||||||
onRepair = { repairing = share },
|
if (shares.isEmpty()) {
|
||||||
onRemove = { removing = share },
|
item(key = "empty") {
|
||||||
)
|
Text(
|
||||||
|
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(shares, key = { it.id }) { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
onRefresh = { onRefresh(share.id) },
|
||||||
|
onRepair = { repairing = share },
|
||||||
|
onRemove = { removing = share },
|
||||||
|
onRename = { renaming = share },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Where a thumb is, and the one thing this page is for. The share
|
||||||
|
// sheet and a tapped link land in the same form.
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showAdd = true },
|
||||||
|
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
|
text = { Text("Add share") },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Where a thumb is, and the one thing this page is for. The share
|
|
||||||
// sheet and a tapped link land in the same form.
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = { showAdd = true },
|
|
||||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
|
||||||
text = { Text("Add share") },
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (showAdd || incomingLink != null) {
|
if (showAdd || incomingLink != null) {
|
||||||
AddShareDialog(
|
AddShareDialog(
|
||||||
@@ -100,6 +135,14 @@ internal fun SharesScreen(
|
|||||||
onAdd = onAdd,
|
onAdd = onAdd,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
|
||||||
|
account.state == AccountState.MailboxPassword)
|
||||||
|
) {
|
||||||
|
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
|
||||||
|
}
|
||||||
|
if (accountActions != null && browsing) {
|
||||||
|
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
|
||||||
|
}
|
||||||
repairing?.let { share ->
|
repairing?.let { share ->
|
||||||
RepairShareDialog(
|
RepairShareDialog(
|
||||||
share = share,
|
share = share,
|
||||||
@@ -107,20 +150,35 @@ internal fun SharesScreen(
|
|||||||
onRepair = { url, password -> onRepair(share.id, url, password) },
|
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
renaming?.let { share ->
|
||||||
|
var name by remember(share.id) { mutableStateOf(share.name) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { renaming = null },
|
||||||
|
title = { Text("Rename ${share.name}") },
|
||||||
|
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(enabled = name.isNotBlank(), onClick = {
|
||||||
|
renaming = null
|
||||||
|
onRename(share.id, name.trim())
|
||||||
|
}) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
removing?.let { share ->
|
removing?.let { share ->
|
||||||
// Asked, because it cannot be undone: the link's secret, its offline
|
// Asked, because it cannot be undone: the link's secret and its
|
||||||
// files and its watch positions all go (`Catalog::remove_share`).
|
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { removing = null },
|
onDismissRequest = { removing = null },
|
||||||
title = { Text("Remove ${share.name}?") },
|
title = { Text("Remove ${share.name}?") },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"Its titles leave the library, and its offline episodes and watch " +
|
"Its titles leave the library and its offline episodes are deleted. " +
|
||||||
"progress are deleted. Adding the link again starts it fresh.",
|
"Watch progress is kept, so adding the link again picks up where you left off.",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
DangerButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
@@ -133,18 +191,58 @@ internal fun SharesScreen(
|
|||||||
* Three differently styled controls beside the name squeezed it into a column
|
* Three differently styled controls beside the name squeezed it into a column
|
||||||
* a word wide; one icon leaves the row its width.
|
* a word wide; one icon leaves the row its width.
|
||||||
*/
|
*/
|
||||||
|
/** "120 files · crawled 2 h ago", or that it has not been yet. */
|
||||||
|
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
|
||||||
|
val at = share.crawledAt ?: return "Not crawled yet"
|
||||||
|
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
|
||||||
|
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
|
||||||
|
internal fun ago(at: Long, now: Long): String {
|
||||||
|
val seconds = (now - at).coerceAtLeast(0)
|
||||||
|
return when {
|
||||||
|
seconds < 60 -> "just now"
|
||||||
|
seconds < 3_600 -> "${seconds / 60} min ago"
|
||||||
|
seconds < 86_400 -> "${seconds / 3_600} h ago"
|
||||||
|
seconds < 172_800 -> "1 day ago"
|
||||||
|
else -> "${seconds / 86_400} days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShareRow(
|
private fun ShareRow(
|
||||||
share: ShareRecord,
|
share: ShareRecord,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onRepair: () -> Unit,
|
onRepair: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
|
onRename: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(if (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 = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -166,14 +264,21 @@ private fun ShareRow(
|
|||||||
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
onClick = { menu = false; onRefresh() },
|
onClick = { menu = false; onRefresh() },
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRename() },
|
||||||
|
)
|
||||||
// Not a remove: a share whose secret has become unreadable
|
// Not a remove: a share whose secret has become unreadable
|
||||||
// cannot be removed either, since removal deletes a secret
|
// cannot be removed either, since removal deletes a secret
|
||||||
// the store can no longer touch.
|
// the store can no longer touch.
|
||||||
DropdownMenuItem(
|
if (!share.fromAccount) {
|
||||||
text = { Text("Re-enter link") },
|
DropdownMenuItem(
|
||||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
text = { Text("Re-enter link") },
|
||||||
onClick = { menu = false; onRepair() },
|
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||||
)
|
onClick = { menu = false; onRepair() },
|
||||||
|
)
|
||||||
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Remove") },
|
text = { Text("Remove") },
|
||||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||||
@@ -241,32 +346,55 @@ private fun RepairShareDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a link. The form stays up while the link is opened, and with the reason
|
||||||
|
* under it when the link is refused, so fixing a typo is a retry rather than
|
||||||
|
* starting over.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddShareDialog(
|
private fun AddShareDialog(
|
||||||
initialUrl: String,
|
initialUrl: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (String, String, String?) -> Unit,
|
onAdd: suspend (String, String, String?) -> String?,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
|
var showLink by remember { mutableStateOf(false) }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
|
var adding by remember { mutableStateOf(false) }
|
||||||
|
var refusal by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = { if (!adding) onDismiss() },
|
||||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
title = { Text("Add Proton Drive share") },
|
title = { Text("Add Proton Drive share") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
url,
|
url,
|
||||||
{ url = it },
|
{ url = it },
|
||||||
label = { Text("Public share URL") },
|
label = { Text("Share link") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
// The URL fragment is a secret. Password input disables
|
// The URL fragment is a secret. Password input disables
|
||||||
// IME learning/suggestions while normal long-press paste
|
// IME learning/suggestions while normal long-press paste
|
||||||
// remains available.
|
// remains available; the eye shows what was pasted.
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showLink = !showLink }) {
|
||||||
|
Icon(
|
||||||
|
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (showLink) "Hide link" else "Show link",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
name,
|
||||||
|
{ name = it },
|
||||||
|
label = { Text("Name (optional)") },
|
||||||
|
supportingText = { Text("Defaults to the shared folder's name") },
|
||||||
|
singleLine = true,
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
password,
|
password,
|
||||||
@@ -276,14 +404,30 @@ private fun AddShareDialog(
|
|||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
)
|
)
|
||||||
|
if (adding) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
Text("Opening the link…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
AccentButton(
|
AccentButton(
|
||||||
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
onClick = {
|
||||||
enabled = name.isNotBlank() && url.isNotBlank(),
|
adding = true
|
||||||
) { Text("Add") }
|
refusal = null
|
||||||
|
scope.launch {
|
||||||
|
refusal = onAdd(name.trim(), url.trim(), password)
|
||||||
|
adding = false
|
||||||
|
if (refusal == null) onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = url.isNotBlank() && !adding,
|
||||||
|
) { Text(if (adding) "Adding…" else "Add") }
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -14,6 +19,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
@@ -23,6 +31,8 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -31,14 +41,17 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.CheckCircle
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.RemoveDone
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -61,12 +74,15 @@ import io.narl.protonstream.download.DownloadStateStore
|
|||||||
import io.narl.protonstream.download.RetainedDownload
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.DangerButton
|
||||||
import io.narl.protonstream.ui.theme.AccentProgress
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
import io.narl.protonstream.ui.theme.EdgedButton
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
import io.narl.protonstream.ui.theme.QuietButton
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
import io.narl.protonstream.ui.theme.solid
|
import io.narl.protonstream.ui.theme.solid
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.MatchRecord
|
import uniffi.pstr_android.MatchRecord
|
||||||
@@ -88,17 +104,35 @@ internal fun TitleScreen(
|
|||||||
onMetadataChanged: () -> Unit,
|
onMetadataChanged: () -> Unit,
|
||||||
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
/** Several episodes seen or unseen at once, named for the message. */
|
||||||
|
onMarkEpisodes: (List<EpisodeRecord>, Boolean, String) -> Unit = { _, _, _ -> },
|
||||||
|
/** Every title of its franchise, in release order, this one included. */
|
||||||
|
franchise: List<TitleRecord> = emptyList(),
|
||||||
|
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val download = rememberDownload()
|
||||||
var showMatch by remember(title.key) { mutableStateOf(false) }
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
// Predictive back: the page shrinks towards the library as the gesture
|
||||||
|
// is dragged, and springs back if it is let go of, so the viewer sees
|
||||||
|
// where back goes before committing to it.
|
||||||
|
var backProgress by remember { mutableFloatStateOf(0f) }
|
||||||
|
PredictiveBackHandler { gesture ->
|
||||||
|
try {
|
||||||
|
gesture.collect { backProgress = it.progress }
|
||||||
|
onBack()
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
backProgress = 0f
|
||||||
|
throw cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Display order across seasons: what previous/next and autoplay walk, and
|
// Display order across seasons: what previous/next and autoplay walk, and
|
||||||
// the same order the desktop client uses.
|
// the same order the desktop client uses.
|
||||||
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
val nextUp = remember(playlist) { nextUpIndex(playlist, title.continueIndex) }
|
||||||
val upNext = playlist.getOrNull(nextUp)
|
val upNext = playlist.getOrNull(nextUp)
|
||||||
// One season on screen at a time, opened on the one Play would land in: a
|
// One season on screen at a time, opened on the one Play would land in: a
|
||||||
// forty-episode show as stacked sections is one long scroll to the part
|
// forty-episode show as stacked sections is one long scroll to the part
|
||||||
@@ -114,173 +148,268 @@ internal fun TitleScreen(
|
|||||||
// without a trip to the Downloads tab.
|
// without a trip to the Downloads tab.
|
||||||
val work by WorkManager.getInstance(context)
|
val work by WorkManager.getInstance(context)
|
||||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||||
val downloads = remember(work) { DownloadStateStore(context).records() }
|
// Decoded off the main thread: WorkManager reports progress several times
|
||||||
|
// a second while a season downloads, and each report re-reads every record.
|
||||||
|
val downloads by produceState(emptyList<RetainedDownload>(), work) {
|
||||||
|
value = withContext(Dispatchers.IO) { DownloadStateStore(context).records() }
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
// button and synopsis lines two thousand pixels wide are read by turning
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
// the head. The backdrop still spans the window, but no taller than about
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
// half of it, or a landscape tablet opened on a picture and nothing else.
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
val scale = 1f - 0.1f * backProgress
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
shape = RoundedCornerShape((32 * backProgress).dp)
|
||||||
|
clip = backProgress > 0f
|
||||||
|
}
|
||||||
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
item(key = "backdrop") {
|
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||||
// Edge to edge, fading into the page: the art is the first thing
|
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||||
// the page says, and a framed thumbnail with a margin round it read
|
LazyColumn(
|
||||||
// as one card among the many below it.
|
modifier = Modifier.fillMaxSize(),
|
||||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) {
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
RemoteArtwork(
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
title.backdropUrl ?: title.posterUrl,
|
) {
|
||||||
title.canonicalName ?: title.name,
|
item(key = "backdrop") {
|
||||||
Modifier.fillMaxSize(),
|
// Edge to edge, fading into the page: the art is the first thing
|
||||||
fallback = title.thumbnailSource,
|
// the page says, and a framed thumbnail with a margin round it read
|
||||||
)
|
// as one card among the many below it.
|
||||||
Box(
|
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||||
Modifier.fillMaxSize().background(
|
RemoteArtwork(
|
||||||
Brush.verticalGradient(
|
title.backdropUrl ?: title.posterUrl,
|
||||||
0.45f to Color.Transparent,
|
title.displayName,
|
||||||
1f to MaterialTheme.colorScheme.background,
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.45f to Color.Transparent,
|
||||||
|
1f to MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
onClick = onBack,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Black.copy(alpha = 0.4f)),
|
|
||||||
) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "heading") {
|
|
||||||
Column(Modifier.padding(horizontal = 16.dp)) {
|
|
||||||
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
|
|
||||||
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
|
||||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
listOfNotNull(
|
|
||||||
(title.metadataYear ?: title.year)?.toString(),
|
|
||||||
title.rating?.let { "★ %.1f".format(it) },
|
|
||||||
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
|
||||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
|
||||||
).joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
)
|
|
||||||
// The one thing the page is for, full width and naming what it
|
|
||||||
// will play: "Resume" alone left the viewer guessing which
|
|
||||||
// episode of forty it meant.
|
|
||||||
AccentButton(
|
|
||||||
onClick = { onPlay(playlist, nextUp) },
|
|
||||||
enabled = playerReady && playlist.isNotEmpty(),
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
when {
|
|
||||||
!playerReady -> "Player loading…"
|
|
||||||
upNext == null -> "Play"
|
|
||||||
upNext.resumeAt != null -> "Resume ${upNext.label}"
|
|
||||||
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
|
|
||||||
playlist.size == 1 -> "Play"
|
|
||||||
else -> "Play ${upNext.label}"
|
|
||||||
},
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
)
|
||||||
}
|
IconButton(
|
||||||
// Everything else as labelled icons in one row: four actions of
|
onClick = onBack,
|
||||||
// three button styles used to wrap over three lines and bury
|
|
||||||
// the episodes under them.
|
|
||||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
|
||||||
// Only where it would do something different: an unstarted
|
|
||||||
// show already starts at the beginning.
|
|
||||||
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
|
||||||
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
|
|
||||||
onPlay(playlist, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
|
||||||
DownloadCoordinator.enqueue(context, playlist)
|
|
||||||
}
|
|
||||||
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
|
||||||
// The provider's own page for this title: where a viewer goes
|
|
||||||
// to check that the thing the app matched is what they have.
|
|
||||||
title.externalUrl?.let { url ->
|
|
||||||
TitleAction(
|
|
||||||
Icons.AutoMirrored.Filled.OpenInNew,
|
|
||||||
title.metadataProvider?.displayName() ?: "Provider",
|
|
||||||
Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
runCatching {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
|
||||||
}.onFailure(onPreferenceError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
title.overview?.let { overview ->
|
|
||||||
// Three lines, then a tap for the rest: a synopsis with its
|
|
||||||
// source notes ran to a screen and a half on a phone.
|
|
||||||
Text(
|
|
||||||
overview.trim(),
|
|
||||||
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 12.dp)
|
.statusBarsPadding()
|
||||||
.clickable { overviewOpen = !overviewOpen },
|
.padding(8.dp)
|
||||||
)
|
.clip(CircleShape)
|
||||||
}
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
// Said plainly, because it changes what a re-match will do: a
|
) {
|
||||||
// hand-picked match is not overwritten by an automatic pass.
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||||
if (title.manualMatch) {
|
}
|
||||||
Text(
|
|
||||||
"Matched by hand",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
item(key = "heading") {
|
||||||
item(key = "seasons") {
|
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||||
Row(
|
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||||
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
|
// The other names it goes by: the share's when the shown one
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
// is the provider's, and the original-language one.
|
||||||
) {
|
listOfNotNull(
|
||||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
title.name.takeIf { it != title.displayName },
|
||||||
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
title.originalName?.takeIf { it != title.displayName && it != title.canonicalName },
|
||||||
FilterChip(
|
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||||
selected = index == seasonIndex,
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
onClick = { seasonIndex = index },
|
}
|
||||||
label = { Text(entry.label) },
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.formatLabel,
|
||||||
|
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
|
||||||
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
|
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
// Who made it, and whether more is coming — the desktop's
|
||||||
|
// line of facts, on a line of its own so neither wraps.
|
||||||
|
listOfNotNull(
|
||||||
|
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
||||||
|
title.directors.firstOrNull()?.let { "dir. $it" },
|
||||||
|
airingNote(title),
|
||||||
|
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The one thing the page is for, full width and naming what it
|
||||||
|
// will play: "Resume" alone left the viewer guessing which
|
||||||
|
// episode of forty it meant.
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onPlay(playlist, nextUp) },
|
||||||
|
enabled = playerReady && playlist.isNotEmpty(),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
!playerReady -> "Player loading…"
|
||||||
|
upNext == null -> "Play"
|
||||||
|
upNext.resumeAt != null -> "Resume ${upNext.label}"
|
||||||
|
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
|
||||||
|
playlist.size == 1 -> "Play"
|
||||||
|
else -> "Play ${upNext.label}"
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Everything else as labelled icons in one row: four actions of
|
||||||
|
// three button styles used to wrap over three lines and bury
|
||||||
|
// the episodes under them.
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
// Only where it would do something different: an unstarted
|
||||||
|
// show already starts at the beginning.
|
||||||
|
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||||
|
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
|
||||||
|
onPlay(playlist, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||||
|
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
|
||||||
|
// to check that the thing the app matched is what they have.
|
||||||
|
title.externalUrl?.let { url ->
|
||||||
|
TitleAction(
|
||||||
|
Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
title.metadataProvider?.displayName() ?: "Provider",
|
||||||
|
Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||||
|
}.onFailure(onPreferenceError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title.overview?.let { overview ->
|
||||||
|
// Three lines, then a tap for the rest: a synopsis with its
|
||||||
|
// source notes ran to a screen and a half on a phone.
|
||||||
|
Text(
|
||||||
|
overview.trim(),
|
||||||
|
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.clickable { overviewOpen = !overviewOpen },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Genres as chips, then the provider's tags quieter after
|
||||||
|
// them: a tag is its community describing it, not what it
|
||||||
|
// is filed as.
|
||||||
|
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
(title.genres + title.tags.take(4)).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Said plainly, because it changes what a re-match will do: a
|
||||||
|
// hand-picked match is not overwritten by an automatic pass.
|
||||||
|
if (title.manualMatch) {
|
||||||
|
Text(
|
||||||
|
"Matched by hand",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
season?.let {
|
}
|
||||||
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
if (franchise.size > 1) {
|
||||||
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
item(key = "franchise") {
|
||||||
|
Column(Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
"In this franchise",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = side + 16.dp),
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(franchise, key = { it.key }) { member ->
|
||||||
|
FranchiseTile(member, here = member.key == title.key) { onOpenTitle(member) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
item(key = "seasons") {
|
||||||
season?.let { shown ->
|
Row(
|
||||||
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
EpisodeRow(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
episode = episode,
|
) {
|
||||||
numbering = episode.numbering(shown.number, position),
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
position = position,
|
// By position: two seasons can share a label, and a
|
||||||
download = downloads.firstOrNull {
|
// repeated key is a crash in a lazy list.
|
||||||
it.shareId == episode.shareId && it.linkId == episode.linkId
|
itemsIndexed(title.seasons, key = { index, _ -> index }) { index, entry ->
|
||||||
},
|
FilterChip(
|
||||||
playerReady = playerReady,
|
selected = index == seasonIndex,
|
||||||
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
onClick = { seasonIndex = index },
|
||||||
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
label = { Text(entry.label) },
|
||||||
onPause = { DownloadCoordinator.pause(context, it) },
|
)
|
||||||
onResume = { DownloadCoordinator.resume(context, it) },
|
}
|
||||||
onSetWatched = { onSetWatched(episode, it) },
|
}
|
||||||
)
|
season?.let {
|
||||||
|
// 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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
season?.let { shown ->
|
||||||
|
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||||
|
Box(Modifier.padding(horizontal = side)) {
|
||||||
|
EpisodeRow(
|
||||||
|
episode = episode,
|
||||||
|
numbering = episode.numbering(shown.number, position),
|
||||||
|
position = position,
|
||||||
|
download = downloads.firstOrNull {
|
||||||
|
it.shareId == episode.shareId && it.linkId == episode.linkId
|
||||||
|
},
|
||||||
|
playerReady = playerReady,
|
||||||
|
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
||||||
|
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
||||||
|
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}",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +423,71 @@ internal fun TitleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One part of the franchise: its cover, what it is and when. This title's own
|
||||||
|
* is marked rather than left out, so the row says where it sits in the story.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(96.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(enabled = !here, onClick = onClick),
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.posterUrl ?: title.backdropUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.then(
|
||||||
|
if (here) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
title.displayName,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = if (here) FontWeight.SemiBold else null,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.formatLabel ?: if (title.kind == TitleType.FILM) "Film" else "Series",
|
||||||
|
(title.metadataYear ?: title.year)?.toString(),
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
|
||||||
|
* no date yet, else nothing.
|
||||||
|
*/
|
||||||
|
private fun airingNote(title: TitleRecord): String? {
|
||||||
|
val at = title.nextAiringAt
|
||||||
|
val episode = title.nextEpisode
|
||||||
|
if (at != null && episode != null) {
|
||||||
|
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||||
|
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
|
||||||
|
}
|
||||||
|
return if (title.airing) "airing" else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||||
|
private val READABLE_WIDTH = 720.dp
|
||||||
|
|
||||||
/** One of the title's secondary actions: an icon with its name under it. */
|
/** One of the title's secondary actions: an icon with its name under it. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun TitleAction(
|
private fun TitleAction(
|
||||||
@@ -360,16 +554,35 @@ private fun EpisodeRow(
|
|||||||
onPause: (RetainedDownload) -> Unit,
|
onPause: (RetainedDownload) -> Unit,
|
||||||
onResume: (RetainedDownload) -> Unit,
|
onResume: (RetainedDownload) -> Unit,
|
||||||
onSetWatched: (Boolean) -> Unit,
|
onSetWatched: (Boolean) -> Unit,
|
||||||
|
onWatchedUpTo: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
// A long press, for someone joining a show partway: everything before
|
||||||
|
// this one ticked at once rather than one by one.
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
download?.status == RetainedDownload.STATUS_QUEUED
|
download?.status == RetainedDownload.STATUS_QUEUED
|
||||||
val heading = episode.heading(position)
|
val heading = episode.heading(position)
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = playerReady, onClick = onPlay)
|
.combinedClickable(
|
||||||
|
enabled = playerReady,
|
||||||
|
onClick = onPlay,
|
||||||
|
onLongClick = { menu = true },
|
||||||
|
onLongClickLabel = "More for this episode",
|
||||||
|
)
|
||||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Mark watched up to here") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.DoneAll, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
menu = false
|
||||||
|
onWatchedUpTo()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
// With metadata off the provider has no still, and Proton's own
|
// With metadata off the provider has no still, and Proton's own
|
||||||
// thumbnail is a frame of this very episode. Where it was left sits
|
// thumbnail is a frame of this very episode. Where it was left sits
|
||||||
@@ -386,6 +599,7 @@ private fun EpisodeRow(
|
|||||||
heading,
|
heading,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -474,8 +688,7 @@ private fun EpisodeRow(
|
|||||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
downloadStatusLine(active, rememberQueueReason(active)),
|
||||||
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -483,26 +696,8 @@ private fun EpisodeRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* What an episode row is headed with: "1. Mother and Children".
|
|
||||||
*
|
|
||||||
* The provider's name for the episode, else the name the filename gave it,
|
|
||||||
* else "Episode 1". The bare filename only when none of those exist — a column
|
|
||||||
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
|
||||||
*/
|
|
||||||
private fun EpisodeRecord.heading(position: Int): String {
|
|
||||||
val named = providerName ?: detail.takeIf { it != name }
|
|
||||||
val number = number?.toInt()
|
|
||||||
return when {
|
|
||||||
named != null && number != null -> "$number. $named"
|
|
||||||
named != null -> named
|
|
||||||
number != null -> "Episode $number"
|
|
||||||
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChangeMatchDialog(
|
internal fun ChangeMatchDialog(
|
||||||
title: TitleRecord,
|
title: TitleRecord,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onChanged: () -> Unit,
|
onChanged: () -> Unit,
|
||||||
@@ -510,8 +705,36 @@ private fun ChangeMatchDialog(
|
|||||||
) {
|
) {
|
||||||
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||||
var searching by remember { mutableStateOf(false) }
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
// 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 options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
|
var confirmForget by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
if (confirmForget) {
|
||||||
|
// Asked, because a match chosen by hand is work the next look-up
|
||||||
|
// does not redo: it decides again on its own.
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmForget = false },
|
||||||
|
title = { Text("Forget this match?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Its poster, description and episode names go, and the next look-up " +
|
||||||
|
"matches it again on its own. A match you chose by hand is lost.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
DangerButton(onClick = {
|
||||||
|
confirmForget = false
|
||||||
|
scope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||||
|
.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
}) { Text("Forget") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("Change match") },
|
title = { Text("Change match") },
|
||||||
@@ -528,16 +751,26 @@ private fun ChangeMatchDialog(
|
|||||||
searching = false
|
searching = false
|
||||||
}
|
}
|
||||||
}) { Text(if (searching) "Searching…" else "Search") }
|
}) { Text(if (searching) "Searching…" else "Search") }
|
||||||
|
if (choosing) {
|
||||||
|
Text(
|
||||||
|
"Fetching its episodes…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||||
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||||
EdgedButton(
|
EdgedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
choosing = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||||
}.onSuccess { onChanged() }.onFailure(onError)
|
}.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
choosing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enabled = !choosing,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
@@ -550,12 +783,7 @@ private fun ChangeMatchDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
QuietButton(onClick = {
|
QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
|
||||||
scope.launch {
|
|
||||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
|
||||||
.onSuccess { onChanged() }.onFailure(onError)
|
|
||||||
}
|
|
||||||
}) { Text("Forget match") }
|
|
||||||
},
|
},
|
||||||
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -9,6 +10,7 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
@@ -18,14 +20,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
|
|||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||||
import androidx.compose.material3.SliderState
|
import androidx.compose.material3.SliderState
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,17 +74,30 @@ internal fun AccentButton(
|
|||||||
content: @Composable RowScope.() -> Unit,
|
content: @Composable RowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val shape = MaterialTheme.shapes.small
|
val shape = MaterialTheme.shapes.small
|
||||||
Button(
|
// The button pads itself out to the 48dp minimum touch target, and a
|
||||||
onClick = onClick,
|
// background on its modifier fills that whole target — 8dp taller than
|
||||||
// Only while it is live: a disabled button keeps Material's flat
|
// the 40dp container every other button draws. So the padding is taken
|
||||||
// disabled container, because a greyed control that still wears the
|
// here, outside the ramp, and the button inside is told not to add its
|
||||||
// one strong colour in the window reads as pressable.
|
// own. The box hands its size down, so a `fillMaxWidth` still reaches it.
|
||||||
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
|
Box(
|
||||||
enabled = enabled,
|
modifier.minimumInteractiveComponentSize(),
|
||||||
shape = shape,
|
contentAlignment = Alignment.Center,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
propagateMinConstraints = true,
|
||||||
content = content,
|
) {
|
||||||
)
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
// Only while it is live: a disabled button keeps Material's flat
|
||||||
|
// disabled container, because a greyed control that still wears the
|
||||||
|
// one strong colour in the window reads as pressable.
|
||||||
|
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = shape,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -123,6 +142,28 @@ internal fun QuietButton(
|
|||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The yes of a confirmation that deletes or forgets something.
|
||||||
|
*
|
||||||
|
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
|
||||||
|
* whose most inviting button is the one that loses something reads as the
|
||||||
|
* safe choice. Error-toned text instead, as Material's own guidance has it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun DangerButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
) = TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far along something is, drawn in the accent.
|
* How far along something is, drawn in the accent.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ object AppearanceState {
|
|||||||
.onSuccess { current.value = it }
|
.onSuccess { current.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read the stored choice again, after sync rewrote it. */
|
||||||
|
suspend fun reload() {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||||
|
.onSuccess { current.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
/** Repaint immediately, before the write that stores the choice lands. */
|
/** Repaint immediately, before the write that stores the choice lands. */
|
||||||
fun apply(palette: PaletteRecord) {
|
fun apply(palette: PaletteRecord) {
|
||||||
current.value = palette
|
current.value = palette
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
@@ -2,15 +2,17 @@
|
|||||||
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
|
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
|
||||||
revision is fetched in. The paths are in the SVG's 128-unit space; the group
|
revision is fetched in. The paths are in the SVG's 128-unit space; the group
|
||||||
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
|
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
|
||||||
|
It centres x=70, between the glyph's centroid (66) and its box (73): the
|
||||||
|
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp" android:height="108dp"
|
android:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<group
|
<group
|
||||||
android:pivotX="64" android:pivotY="62"
|
android:pivotX="70" android:pivotY="62"
|
||||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
android:translateX="-10" android:translateY="-8">
|
android:translateX="-16" android:translateY="-8">
|
||||||
<group android:translateY="3">
|
<group android:translateY="3">
|
||||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
<path android:fillColor="#4D2E1596" android:pathData="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" />
|
<path android:fillColor="#4D2E1596" android:pathData="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" />
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
android:width="108dp" android:height="108dp"
|
android:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<group
|
<group
|
||||||
android:pivotX="64" android:pivotY="62"
|
android:pivotX="70" android:pivotY="62"
|
||||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
android:translateX="-10" android:translateY="-8">
|
android:translateX="-16" android:translateY="-8">
|
||||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
<path android:fillColor="#FFFFFFFF" android:pathData="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" />
|
<path android:fillColor="#FFFFFFFF" android:pathData="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" />
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TransferRateTest {
|
||||||
|
@Test
|
||||||
|
fun `the first sample has no rate to report`() {
|
||||||
|
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a steady transfer reports its rate`() {
|
||||||
|
val rate = TransferRate()
|
||||||
|
rate.sample(0, 0)
|
||||||
|
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
|
||||||
|
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one slow block moves the rate without halving it`() {
|
||||||
|
val rate = TransferRate()
|
||||||
|
rate.sample(0, 0)
|
||||||
|
rate.sample(1_000_000, 1_000)
|
||||||
|
val after = rate.sample(2_000_000, 3_000)
|
||||||
|
assertTrue(after in 600_000L..999_999L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a resumed download starting below the last sample starts over`() {
|
||||||
|
val rate = TransferRate()
|
||||||
|
rate.sample(0, 0)
|
||||||
|
rate.sample(1_000_000, 1_000)
|
||||||
|
assertEquals(1_000_000L, rate.sample(0, 2_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AccountUiTest {
|
||||||
|
@Test
|
||||||
|
fun `the sync line says how long ago and what came in`() {
|
||||||
|
val minute = 60_000L
|
||||||
|
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
|
||||||
|
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||||
|
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||||
|
assertEquals(
|
||||||
|
"Synced 2 h ago · 3 from other devices",
|
||||||
|
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed sync is said in place of when it last worked`() {
|
||||||
|
assertEquals(
|
||||||
|
"Did not sync: offline",
|
||||||
|
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the verification page may only navigate within Proton over https`() {
|
||||||
|
assertTrue(isProtonPage("https", "verify.proton.me"))
|
||||||
|
assertTrue(isProtonPage("https", "proton.me"))
|
||||||
|
assertFalse(isProtonPage("http", "verify.proton.me"))
|
||||||
|
assertFalse(isProtonPage("https", "proton.me.example.com"))
|
||||||
|
assertFalse(isProtonPage("https", "notproton.me"))
|
||||||
|
assertFalse(isProtonPage("https", null))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ArtworkTest {
|
||||||
|
@Test
|
||||||
|
fun `initials skip the lower-case particles of a romanised name`() {
|
||||||
|
assertEquals("OK", initials("Oshi no Ko"))
|
||||||
|
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-word name has one initial`() {
|
||||||
|
assertEquals("A", initials("Akira"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a name with no capitals falls back to its first two words`() {
|
||||||
|
assertEquals("BW", initials("blue world order"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `punctuation and separators are not words`() {
|
||||||
|
assertEquals("S2", initials("Steins;Gate - 2"))
|
||||||
|
assertEquals("", initials(" - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ class HistoryTest {
|
|||||||
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||||
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||||
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
|
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
|
||||||
|
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
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LanguagePickerTest {
|
||||||
|
@Test
|
||||||
|
fun `a stored tag reads as its language in either ISO form`() {
|
||||||
|
assertEquals("Japanese", languageLabel("jpn"))
|
||||||
|
assertEquals("Japanese", languageLabel("ja"))
|
||||||
|
assertEquals("German", languageLabel("deu"))
|
||||||
|
assertEquals("German", languageLabel("ger"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a region suffix and capitals do not hide the language`() {
|
||||||
|
assertEquals("Portuguese", languageLabel("PT-br"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tag not on the list is shown as typed, and blank is no preference`() {
|
||||||
|
assertEquals("tgl", languageLabel("tgl"))
|
||||||
|
assertEquals("No preference", languageLabel(null))
|
||||||
|
assertEquals("No preference", languageLabel(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every listed language has a two-letter form`() {
|
||||||
|
LANGUAGES.forEach { (code, name) -> assertEquals(code, name, languageLabel(code)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import androidx.test.core.app.ApplicationProvider
|
|||||||
import androidx.work.testing.WorkManagerTestInitHelper
|
import androidx.work.testing.WorkManagerTestInitHelper
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import io.narl.protonstream.ui.theme.ProtonFallback
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -23,10 +25,17 @@ import org.junit.runner.RunWith
|
|||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import uniffi.pstr_android.MetadataSettingsRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.ShelfRecord
|
||||||
import uniffi.pstr_android.StorageUsageRecord
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.TileRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TitleType
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
@@ -36,8 +45,9 @@ import uniffi.pstr_android.TitleType
|
|||||||
* The screens are about to be redesigned one by one, and a layout regression —
|
* The screens are about to be redesigned one by one, and a layout regression —
|
||||||
* a row that wraps, a button pushed off the edge — compiles and passes every
|
* a row that wraps, a button pushed off the edge — compiles and passes every
|
||||||
* other test. Here it is a changed PNG in review. No artwork is loaded: the
|
* other test. Here it is a changed PNG in review. No artwork is loaded: the
|
||||||
* bridge is not available on the host, so every tile shows its placeholder,
|
* bridge is not available on the host, so every tile shows its generated
|
||||||
* which is also what a title without metadata looks like on a phone.
|
* placeholder, which is also what a title without metadata looks like on a
|
||||||
|
* phone.
|
||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@@ -48,12 +58,22 @@ class ScreenshotTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
||||||
|
// The palette is process-wide and the sandbox outlives a test, so a
|
||||||
|
// light capture would otherwise repaint every one after it.
|
||||||
|
AppearanceState.apply(ProtonFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLight(name: String, content: @Composable () -> Unit) {
|
||||||
|
AppearanceState.apply(LATTE)
|
||||||
|
capture(name, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
CompositionLocalProvider(LocalArtworkLoading provides false) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
@@ -61,13 +81,40 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the library on a phone`() = capture("library-phone") {
|
fun `the library on a phone`() = capture("library-phone") {
|
||||||
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
fun `the library on a tablet`() = capture("library-tablet") {
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||||
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title in the light palette`() = captureLight("title-phone-light") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `a title on a tablet`() = capture("title-tablet") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the history page on a tablet`() = capture("history-tablet") {
|
||||||
|
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -76,7 +123,7 @@ class ScreenshotTest {
|
|||||||
// reads it from, so the scale is set there.
|
// reads it from, so the scale is set there.
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,19 +132,34 @@ class ScreenshotTest {
|
|||||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||||
|
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a title with seasons`() = capture("title-phone") {
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the shares page`() = capture("shares-phone") {
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
SharesScreen(LIBRARY.shares, { _, _, _ -> null }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the settings page`() = capture("settings-phone") {
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues())
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the playback settings`() = capture("settings-playback") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the storage settings`() = capture("settings-storage") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -107,12 +169,42 @@ class ScreenshotTest {
|
|||||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the downloads page with a season saved`() = capture("downloads-phone") {
|
||||||
|
val saved = SERIES.seasons.first().episodes.take(2).map {
|
||||||
|
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
|
||||||
|
}
|
||||||
|
// One title: the fixtures reuse link ids across titles, so with the
|
||||||
|
// whole library the episodes would be filed under whichever came last.
|
||||||
|
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
|
||||||
|
val LATTE = PaletteRecord(
|
||||||
|
background = 0xFFEFF1F5u,
|
||||||
|
surface = 0xFFE6E9EFu,
|
||||||
|
sunken = 0xFFDCE0E8u,
|
||||||
|
card = 0xFFCCD0DAu,
|
||||||
|
cardHover = 0xFFBCC0CCu,
|
||||||
|
border = 0xFFBCC0CCu,
|
||||||
|
text = 0xFF4C4F69u,
|
||||||
|
muted = 0xFF6C6F85u,
|
||||||
|
accent = 0xFF732FCCu,
|
||||||
|
accentAlt = 0xFF1856D1u,
|
||||||
|
accentDim = 0xFFC4BBE4u,
|
||||||
|
onAccent = 0xFFEFF1F5u,
|
||||||
|
danger = 0xFFD20F39u,
|
||||||
|
elevated = 0xFFAAAEBDu,
|
||||||
|
dangerDim = 0xFFE7D1D5u,
|
||||||
|
light = true,
|
||||||
|
)
|
||||||
|
|
||||||
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
||||||
// `detail` is the filename unless the filename named the episode,
|
// `detail` is the filename unless the filename named the episode,
|
||||||
// which is what the bridge sends.
|
// which is what the bridge sends.
|
||||||
@@ -168,6 +260,20 @@ class ScreenshotTest {
|
|||||||
externalUrl = null,
|
externalUrl = null,
|
||||||
manualMatch = false,
|
manualMatch = false,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
|
displayName = name,
|
||||||
|
wideUrl = null,
|
||||||
|
formatLabel = null,
|
||||||
|
seasonLabel = null,
|
||||||
|
studios = emptyList(),
|
||||||
|
directors = emptyList(),
|
||||||
|
tags = emptyList(),
|
||||||
|
airing = false,
|
||||||
|
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(
|
val SERIES = title(
|
||||||
@@ -178,11 +284,32 @@ class ScreenshotTest {
|
|||||||
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
|
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
|
||||||
),
|
),
|
||||||
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
|
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
|
||||||
|
).copy(
|
||||||
|
formatLabel = "TV",
|
||||||
|
seasonLabel = "Spring 2023",
|
||||||
|
studios = listOf("Doga Kobo"),
|
||||||
|
directors = listOf("Daisuke Hiramaki"),
|
||||||
|
tags = listOf("Idol", "Reincarnation"),
|
||||||
|
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
|
||||||
|
val SEQUEL = title("oshi-no-ko-film", "Oshi no Ko: The Final Act", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
|
.copy(kind = TitleType.FILM, year = 2024u, formatLabel = "Film", franchise = SERIES.franchise)
|
||||||
|
|
||||||
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
.copy(kind = TitleType.FILM, year = 1988u)
|
.copy(kind = TitleType.FILM, year = 1988u)
|
||||||
|
|
||||||
|
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
|
||||||
|
val MATCHED get() = LIBRARY.copy(
|
||||||
|
metadataSettings = MetadataSettingsRecord(
|
||||||
|
enabled = true,
|
||||||
|
provider = MetadataProvider.ANI_LIST,
|
||||||
|
language = "en",
|
||||||
|
ready = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val LIBRARY = AppUiState(
|
val LIBRARY = AppUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
titles = listOf(
|
titles = listOf(
|
||||||
@@ -191,9 +318,25 @@ class ScreenshotTest {
|
|||||||
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
|
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
|
||||||
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
|
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
|
||||||
),
|
),
|
||||||
|
// Bleach stands for one more title of its franchise, and one shelf
|
||||||
|
// sits above the grid: what the bridge's arrangement would give.
|
||||||
|
arrangement = ArrangementRecord(
|
||||||
|
tiles = listOf("akira", "ano-hana", "bleach", "oshi-no-ko").map {
|
||||||
|
TileRecord(it, if (it == "bleach") 1u else 0u)
|
||||||
|
},
|
||||||
|
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
||||||
|
),
|
||||||
|
// Crawled relative to the clock, so the row reads "2 h ago" on
|
||||||
|
// whatever day the test runs.
|
||||||
shares = listOf(
|
shares = listOf(
|
||||||
ShareRecord("anime", "anime", true),
|
ShareRecord(
|
||||||
ShareRecord("films", "films", false),
|
"anime",
|
||||||
|
"anime",
|
||||||
|
true,
|
||||||
|
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
|
||||||
|
crawlFiles = 120uL,
|
||||||
|
),
|
||||||
|
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
|
||||||
),
|
),
|
||||||
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||||
)
|
)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 813 KiB |
|
After Width: | Height: | Size: 768 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 806 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -24,6 +24,7 @@ pstr-core.workspace = true
|
|||||||
pstr-meta.workspace = true
|
pstr-meta.workspace = true
|
||||||
pstr-stream.workspace = true
|
pstr-stream.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
uniffi.workspace = true
|
uniffi.workspace = true
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ tracing-subscriber.workspace = true
|
|||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
egui-phosphor.workspace = true
|
egui-phosphor.workspace = true
|
||||||
|
# The parser egui reads fonts with, to check a system font before handing it over.
|
||||||
|
skrifa.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -33,9 +35,12 @@ serde_json.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
souvlaki.workspace = true
|
souvlaki.workspace = true
|
||||||
|
wry.workspace = true
|
||||||
|
tao.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
skrifa.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus.workspace = true
|
dbus.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Compiles `proton-stream.rc` into the Windows exe; nothing on any other
|
||||||
|
//! target. `rc.exe` on an MSVC build, mingw's `windres` on the cross build
|
||||||
|
//! from Linux.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=proton-stream.rc");
|
||||||
|
println!("cargo:rerun-if-changed=../../packaging/windows/proton-stream.ico");
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only the GUI gets the icon; `pstr` is a console tool.
|
||||||
|
embed_resource::compile_for("proton-stream.rc", ["proton-stream"], embed_resource::NONE)
|
||||||
|
.manifest_optional()
|
||||||
|
.expect("compile the icon resource");
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// The exe's icon. The window sets one at runtime too, but that only lasts as
|
||||||
|
// long as the window: Explorer, the Start menu and a taskbar pin read the icon
|
||||||
|
// out of the file, and without this one they fall back to the generic one.
|
||||||
|
1 ICON "../../packaging/windows/proton-stream.ico"
|
||||||
@@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// What the settings page offers for the streaming cache, in GiB.
|
/// What the settings page offers for the streaming cache, in GiB.
|
||||||
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
||||||
|
|
||||||
|
/// What the settings page offers for the size of everything, in percent.
|
||||||
|
pub const TEXT_SIZE_CHOICES: [u16; 5] = [90, 100, 115, 130, 150];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
// A file from an older version, or edited by hand, missing a field: the rest
|
// A file from an older version, or edited by hand, missing a field: the rest
|
||||||
// still counts, and the missing one takes its default.
|
// still counts, and the missing one takes its default.
|
||||||
@@ -21,6 +24,15 @@ pub struct DesktopPrefs {
|
|||||||
pub hardware_decoding: bool,
|
pub hardware_decoding: bool,
|
||||||
/// How much decrypted video the disk cache may keep.
|
/// How much decrypted video the disk cache may keep.
|
||||||
pub cache_gib: u32,
|
pub cache_gib: u32,
|
||||||
|
/// How the library was last arranged, so it opens that way.
|
||||||
|
pub filter: crate::app::Filter,
|
||||||
|
pub sort: pstr_core::browse::Sort,
|
||||||
|
pub grouped: bool,
|
||||||
|
/// The library's offer of posters and synopses was turned down.
|
||||||
|
pub metadata_offer_dismissed: bool,
|
||||||
|
/// How large text and controls are drawn, in percent. Per computer, not
|
||||||
|
/// synced: what reads well depends on the screen and how far away it is.
|
||||||
|
pub text_size: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DesktopPrefs {
|
impl Default for DesktopPrefs {
|
||||||
@@ -28,11 +40,23 @@ impl Default for DesktopPrefs {
|
|||||||
Self {
|
Self {
|
||||||
hardware_decoding: true,
|
hardware_decoding: true,
|
||||||
cache_gib: 4,
|
cache_gib: 4,
|
||||||
|
filter: crate::app::Filter::default(),
|
||||||
|
sort: pstr_core::browse::Sort::default(),
|
||||||
|
// On: a share holding a series and its three films is four tiles
|
||||||
|
// of the same picture otherwise.
|
||||||
|
grouped: true,
|
||||||
|
metadata_offer_dismissed: false,
|
||||||
|
text_size: 100,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DesktopPrefs {
|
impl DesktopPrefs {
|
||||||
|
/// [`Self::text_size`] as egui's zoom factor, kept to what is offered.
|
||||||
|
pub fn zoom(&self) -> f32 {
|
||||||
|
f32::from(self.text_size.clamp(50, 200)) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cache_bytes(&self) -> u64 {
|
pub fn cache_bytes(&self) -> u64 {
|
||||||
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||||
}
|
}
|
||||||
@@ -93,6 +117,17 @@ mod tests {
|
|||||||
assert!(prefs.hardware_decoding);
|
assert!(prefs.hardware_decoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_text_size_from_an_older_file_is_full_size_and_a_wild_one_is_kept_in_range() {
|
||||||
|
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
|
||||||
|
assert_eq!(prefs.zoom(), 1.0);
|
||||||
|
let wild = DesktopPrefs {
|
||||||
|
text_size: 900,
|
||||||
|
..DesktopPrefs::default()
|
||||||
|
};
|
||||||
|
assert_eq!(wild.zoom(), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unparseable_file_is_never_overwritten() {
|
fn an_unparseable_file_is_never_overwritten() {
|
||||||
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||||
|
|||||||
@@ -30,5 +30,6 @@ pub mod pacing;
|
|||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod verify;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
@@ -16,6 +16,12 @@ use anyhow::Context;
|
|||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
|
// Before anything else: a verification window is a child process of the
|
||||||
|
// app, and must not claim the single instance or open a library.
|
||||||
|
if let Some(code) = pstr_app::verify::run_if_requested() {
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode;
|
|||||||
use pstr_core::library::{Episode, Title};
|
use pstr_core::library::{Episode, Title};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_player::{
|
use pstr_player::{
|
||||||
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
|
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track,
|
||||||
VideoOutput,
|
TrackKind, VideoOutput,
|
||||||
};
|
};
|
||||||
use pstr_stream::VideoStream;
|
use pstr_stream::VideoStream;
|
||||||
|
|
||||||
@@ -338,6 +338,18 @@ impl Playback {
|
|||||||
(end > self.position + 0.5).then_some((label, end))
|
(end > self.position + 0.5).then_some((label, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the chapters say where the credits start. Most files that are
|
||||||
|
/// not anime have no chapters at all.
|
||||||
|
pub fn knows_credits(&self) -> bool {
|
||||||
|
self.credits_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds of the file left to play, once its length is known.
|
||||||
|
pub fn remaining(&self) -> Option<f64> {
|
||||||
|
self.duration
|
||||||
|
.map(|duration| (duration - self.position).max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the playhead is inside the run of credits and preview that ends
|
/// Whether the playhead is inside the run of credits and preview that ends
|
||||||
/// the file — what "the episode is over" means when a file states its
|
/// the file — what "the episode is over" means when a file states its
|
||||||
/// chapters.
|
/// chapters.
|
||||||
@@ -504,7 +516,11 @@ fn run(
|
|||||||
orders: std::sync::mpsc::Receiver<Command>,
|
orders: std::sync::mpsc::Receiver<Command>,
|
||||||
finished: Arc<AtomicBool>,
|
finished: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
let handle = match player.play(stream) {
|
let opened = match target.resume_at {
|
||||||
|
Some(at) => player.play_from(stream, at),
|
||||||
|
None => player.play(stream),
|
||||||
|
};
|
||||||
|
let handle = match opened {
|
||||||
Ok(handle) => handle,
|
Ok(handle) => handle,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
|
||||||
@@ -514,10 +530,18 @@ fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Opened while mpv opens the video, and added once it has: an external
|
||||||
|
// track belongs to the file loaded at the time.
|
||||||
|
let mut sidecars = Some(engine.runtime().spawn({
|
||||||
|
let engine = engine.clone();
|
||||||
|
let target = target.clone();
|
||||||
|
async move { engine.open_sidecars(&target).await }
|
||||||
|
}));
|
||||||
|
let mut subtitles: Vec<StreamHandle> = Vec::new();
|
||||||
|
|
||||||
let mut position = target.resume_at.unwrap_or(0.0);
|
let mut position = target.resume_at.unwrap_or(0.0);
|
||||||
let mut duration: Option<f64> = None;
|
let mut duration: Option<f64> = None;
|
||||||
let mut watched = false;
|
let mut watched = false;
|
||||||
let mut resumed = target.resume_at.is_none();
|
|
||||||
let mut last_save = std::time::Instant::now();
|
let mut last_save = std::time::Instant::now();
|
||||||
|
|
||||||
'playback: loop {
|
'playback: loop {
|
||||||
@@ -543,6 +567,10 @@ fn run(
|
|||||||
match &event {
|
match &event {
|
||||||
PlayerEvent::FileLoaded => {
|
PlayerEvent::FileLoaded => {
|
||||||
duration = player.duration();
|
duration = player.duration();
|
||||||
|
if let Some(opening) = sidecars.take() {
|
||||||
|
let opened = engine.runtime().block_on(opening).unwrap_or_default();
|
||||||
|
subtitles = add_sidecars(&engine, &target, &player, opened);
|
||||||
|
}
|
||||||
// The first moment there is a track list to read: mpv has
|
// The first moment there is a track list to read: mpv has
|
||||||
// demuxed the file and made its own default selection. Put the
|
// demuxed the file and made its own default selection. Put the
|
||||||
// title's own choice over it before the menus are told, so they
|
// title's own choice over it before the menus are told, so they
|
||||||
@@ -550,14 +578,16 @@ fn run(
|
|||||||
if let Some(show) = target.track_prefs.as_deref() {
|
if let Some(show) = target.track_prefs.as_deref() {
|
||||||
restore_tracks(&player, show);
|
restore_tracks(&player, show);
|
||||||
}
|
}
|
||||||
emit_tracks(&engine, id, &player);
|
// A subtitle track picked by name stays; one mpv picked by
|
||||||
// Only now is there a timeline to seek within.
|
// language may be the signs and songs, not the dialogue.
|
||||||
if !resumed && let Some(at) = target.resume_at {
|
let picked = target
|
||||||
resumed = true;
|
.track_prefs
|
||||||
if let Err(error) = player.seek_to(at) {
|
.as_deref()
|
||||||
tracing::warn!("resume at {at}: {error}");
|
.is_some_and(|show| show.subtitle_title.is_some());
|
||||||
}
|
if !picked {
|
||||||
|
prefer_full_subtitles(&player);
|
||||||
}
|
}
|
||||||
|
emit_tracks(&engine, id, &player);
|
||||||
}
|
}
|
||||||
PlayerEvent::Duration(value) => duration = Some(*value),
|
PlayerEvent::Duration(value) => duration = Some(*value),
|
||||||
PlayerEvent::Position(value) => {
|
PlayerEvent::Position(value) => {
|
||||||
@@ -599,6 +629,7 @@ fn run(
|
|||||||
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
// the other `Arc`, and mpv must be destroyed there, on the thread whose
|
||||||
// OpenGL context its render context was built against.
|
// OpenGL context its render context was built against.
|
||||||
drop(handle);
|
drop(handle);
|
||||||
|
drop(subtitles);
|
||||||
drop(player);
|
drop(player);
|
||||||
|
|
||||||
engine.release(
|
engine.release(
|
||||||
@@ -713,6 +744,57 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play the dialogue rather than the signs and songs mpv picked by language.
|
||||||
|
/// Not remembered: it is the choice mpv should have made, not the viewer's.
|
||||||
|
fn prefer_full_subtitles(player: &Player) {
|
||||||
|
if let Some(track) = pstr_player::full_subtitles_instead(&player.tracks())
|
||||||
|
&& let Err(error) = player.select_track(TrackKind::Subtitle, Some(track))
|
||||||
|
{
|
||||||
|
tracing::warn!("select dialogue subtitle {track}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand mpv the subtitle files paired with the video it has just loaded.
|
||||||
|
///
|
||||||
|
/// One is turned on only when subtitles are on and the video has none of its
|
||||||
|
/// own playing — a file's own track was chosen by name and language already,
|
||||||
|
/// and an external one is the fallback, not an override.
|
||||||
|
fn add_sidecars(
|
||||||
|
engine: &Engine,
|
||||||
|
target: &PlaybackTarget,
|
||||||
|
player: &Player,
|
||||||
|
opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>,
|
||||||
|
) -> Vec<StreamHandle> {
|
||||||
|
let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref());
|
||||||
|
let playing = player
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.any(|track| track.kind == TrackKind::Subtitle && track.selected);
|
||||||
|
let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
|
||||||
|
let select = (config.subtitles && !playing)
|
||||||
|
.then(|| {
|
||||||
|
pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref())
|
||||||
|
})
|
||||||
|
.flatten();
|
||||||
|
sidecars
|
||||||
|
.iter()
|
||||||
|
.zip(streams)
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, (sidecar, stream))| {
|
||||||
|
let title = sidecar.title.as_deref().unwrap_or("External");
|
||||||
|
player
|
||||||
|
.add_subtitle(
|
||||||
|
stream,
|
||||||
|
title,
|
||||||
|
sidecar.language.as_deref(),
|
||||||
|
select == Some(index),
|
||||||
|
)
|
||||||
|
.inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the UI what the file contains and what of it is playing.
|
/// Tell the UI what the file contains and what of it is playing.
|
||||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||||
engine.emit(Event::Player {
|
engine.emit(Event::Player {
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ pub const CARD_WIDTH: f32 = 232.0;
|
|||||||
/// Video stills are 16:9. A poster shape would letterbox every one of them.
|
/// Video stills are 16:9. A poster shape would letterbox every one of them.
|
||||||
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
||||||
pub const CARD_GAP: f32 = 14.0;
|
pub const CARD_GAP: f32 = 14.0;
|
||||||
|
/// The poster grid's minimum width: narrower than a still, because a cover is
|
||||||
|
/// read by its art rather than by detail, and six across a laptop screen is
|
||||||
|
/// what makes a wall of covers look like a shelf.
|
||||||
|
pub const POSTER_WIDTH: f32 = 168.0;
|
||||||
|
/// Covers are 2:3.
|
||||||
|
pub const POSTER_ASPECT: f32 = 3.0 / 2.0;
|
||||||
|
|
||||||
/// The type ramp, named once.
|
/// The type ramp, named once.
|
||||||
///
|
///
|
||||||
@@ -242,15 +248,40 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
|||||||
let Some((path, index)) = system_cjk_font() else {
|
let Some((path, index)) = system_cjk_font() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(bytes) = std::fs::read(path) else {
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(data) = cjk_font_data(bytes, index) else {
|
||||||
|
tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
|
||||||
data.index = index;
|
|
||||||
ctx.set_fonts(font_definitions(Some(data)));
|
ctx.set_fonts(font_definitions(Some(data)));
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fallback font, if egui can read it.
|
||||||
|
///
|
||||||
|
/// egui panics on a font it cannot parse, and this one comes from whatever the
|
||||||
|
/// system has installed. So it is parsed here first, the same way egui does,
|
||||||
|
/// and a font that fails is left out rather than taking the app down.
|
||||||
|
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
|
||||||
|
skrifa::FontRef::from_index(&bytes, index).ok()?;
|
||||||
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
|
data.index = index;
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face within a font collection that fontconfig's `%{index}` names.
|
||||||
|
///
|
||||||
|
/// Fontconfig packs a variable font's named instance into the high 16 bits —
|
||||||
|
/// Fedora's variable Noto Sans CJK answers 262144, instance 4 of face 0. The
|
||||||
|
/// instance is not a face, and passing it to egui as one stopped the app at
|
||||||
|
/// start.
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
fn fontconfig_face(index: u32) -> u32 {
|
||||||
|
index & 0xFFFF
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||||
let output = std::process::Command::new("fc-match")
|
let output = std::process::Command::new("fc-match")
|
||||||
@@ -263,6 +294,7 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|||||||
let index = lines
|
let index = lines
|
||||||
.next()
|
.next()
|
||||||
.and_then(|value| value.parse().ok())
|
.and_then(|value| value.parse().ok())
|
||||||
|
.map(fontconfig_face)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
path.is_file().then_some((path, index))
|
path.is_file().then_some((path, index))
|
||||||
}
|
}
|
||||||
@@ -849,6 +881,20 @@ const RAMP_STOPS: usize = 64;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
||||||
|
assert_eq!(fontconfig_face(262_144), 0);
|
||||||
|
assert_eq!(fontconfig_face(0x0002_0001), 1);
|
||||||
|
assert_eq!(fontconfig_face(2), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fallback_font_egui_cannot_read_is_left_out() {
|
||||||
|
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
|
||||||
|
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
|
||||||
|
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||||
use skrifa::MetadataProvider as _;
|
use skrifa::MetadataProvider as _;
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
//! The Proton account, on the shares page: signing in, watch-history sync, and
|
||||||
|
//! picking folders of the viewer's own Drive as library shares.
|
||||||
|
//!
|
||||||
|
//! Optional and said so: public links need no account, and the page reads the
|
||||||
|
//! same without one. What signing in adds is spelled out next to the form,
|
||||||
|
//! because "sign in" alone does not say why an app that streams public links
|
||||||
|
//! would want a password.
|
||||||
|
|
||||||
|
use pstr_core::account::{DriveEntry, DrivePlace, PlaceKind};
|
||||||
|
use pstr_core::proton_sdk::ids::NodeUid;
|
||||||
|
use pstr_core::sync::SyncReport;
|
||||||
|
|
||||||
|
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)]
|
||||||
|
pub struct AccountPanel {
|
||||||
|
pub status: AccountStatus,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub code: String,
|
||||||
|
pub mailbox: String,
|
||||||
|
/// A request is out and not answered yet.
|
||||||
|
pub busy: bool,
|
||||||
|
/// Why the last sign-in step was refused.
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// When watch history last synced, in Unix seconds, and what it did.
|
||||||
|
pub synced: Option<(i64, SyncReport)>,
|
||||||
|
/// Why the last sync failed, until one succeeds.
|
||||||
|
pub sync_error: Option<String>,
|
||||||
|
pub browser: DriveBrowser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountPanel {
|
||||||
|
/// Apply where the account now stands. The typed secrets go as soon as
|
||||||
|
/// they are not needed: a password has no business sitting in a struct for
|
||||||
|
/// the rest of the session.
|
||||||
|
pub fn set_status(&mut self, status: AccountStatus) {
|
||||||
|
self.busy = false;
|
||||||
|
match &status {
|
||||||
|
AccountStatus::SignedIn(_) | AccountStatus::SignedOut => {
|
||||||
|
self.password.clear();
|
||||||
|
self.code.clear();
|
||||||
|
self.mailbox.clear();
|
||||||
|
if matches!(status, AccountStatus::SignedIn(_)) {
|
||||||
|
self.error = None;
|
||||||
|
} else {
|
||||||
|
self.browser = DriveBrowser::default();
|
||||||
|
self.synced = None;
|
||||||
|
self.sync_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AccountStatus::SecondFactor => self.error = None,
|
||||||
|
AccountStatus::MailboxPassword => {
|
||||||
|
self.code.clear();
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
AccountStatus::Unknown => {}
|
||||||
|
}
|
||||||
|
self.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refused(&mut self, error: String) {
|
||||||
|
self.busy = false;
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the Drive browser is.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DriveBrowser {
|
||||||
|
pub open: bool,
|
||||||
|
/// The folders descended through, each by name. Empty means the places.
|
||||||
|
pub trail: Vec<(String, NodeUid)>,
|
||||||
|
pub places: Option<Vec<DrivePlace>>,
|
||||||
|
/// The listing of the folder at the end of the trail, once it arrives.
|
||||||
|
pub entries: Option<(NodeUid, Vec<DriveEntry>)>,
|
||||||
|
pub loading: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DriveBrowser {
|
||||||
|
pub fn places_arrived(&mut self, places: Vec<DrivePlace>) {
|
||||||
|
self.loading = false;
|
||||||
|
self.error = None;
|
||||||
|
self.places = Some(places);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a listing, unless the viewer has already moved on from it.
|
||||||
|
pub fn folder_arrived(&mut self, uid: NodeUid, entries: Vec<DriveEntry>) {
|
||||||
|
if self
|
||||||
|
.trail
|
||||||
|
.last()
|
||||||
|
.is_some_and(|(_, current)| *current == uid)
|
||||||
|
{
|
||||||
|
self.loading = false;
|
||||||
|
self.error = None;
|
||||||
|
self.entries = Some((uid, entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed(&mut self, error: String) {
|
||||||
|
self.loading = false;
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter(&mut self, name: String, uid: NodeUid, actions: &mut Vec<Action>) {
|
||||||
|
self.trail.push((name, uid.clone()));
|
||||||
|
self.entries = None;
|
||||||
|
self.loading = true;
|
||||||
|
self.error = None;
|
||||||
|
actions.push(Action::BrowseFolder(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn up(&mut self, actions: &mut Vec<Action>) {
|
||||||
|
self.trail.pop();
|
||||||
|
self.entries = None;
|
||||||
|
self.error = None;
|
||||||
|
match self.trail.last() {
|
||||||
|
Some((_, uid)) => {
|
||||||
|
self.loading = true;
|
||||||
|
actions.push(Action::BrowseFolder(uid.clone()));
|
||||||
|
}
|
||||||
|
None => self.loading = self.places.is_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account card: a sign-in form, or who is signed in and how sync is doing.
|
||||||
|
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui::section(ui, "Proton account");
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Optional. Signed in, your shares, settings and watch history are kept in your own \
|
||||||
|
Drive, so every device shows the same library and resumes where another left off, \
|
||||||
|
and folders of your Drive — your files, other devices, what was shared with you — \
|
||||||
|
can be added to the library.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
card(ui, |ui| match panel.status.clone() {
|
||||||
|
AccountStatus::Unknown => {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted("reading the stored session…"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AccountStatus::SignedOut => sign_in_form(ui, panel, actions),
|
||||||
|
AccountStatus::SecondFactor => code_form(ui, panel, actions),
|
||||||
|
AccountStatus::MailboxPassword => mailbox_form(ui, panel, actions),
|
||||||
|
AccountStatus::SignedIn(username) => signed_in(ui, panel, &username, actions),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(14))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
|
add(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_in_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui.label(ui::muted("Address"));
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.username)
|
||||||
|
.hint_text("you@proton.me")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.label(ui::muted("Password"));
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.password)
|
||||||
|
.password(true)
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
error_line(ui, panel);
|
||||||
|
let ready = !panel.username.trim().is_empty() && !panel.password.is_empty() && !panel.busy;
|
||||||
|
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let clicked = ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Sign in"))
|
||||||
|
.inner
|
||||||
|
.clicked();
|
||||||
|
if ready && (clicked || submitted) {
|
||||||
|
panel.busy = true;
|
||||||
|
panel.error = None;
|
||||||
|
actions.push(Action::SignIn {
|
||||||
|
username: panel.username.trim().to_owned(),
|
||||||
|
password: panel.password.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
busy_line(ui, panel, "signing in…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui.label(ui::muted("Two-factor code from your authenticator app"));
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.code)
|
||||||
|
.hint_text("123456")
|
||||||
|
.desired_width(160.0),
|
||||||
|
);
|
||||||
|
field.request_focus();
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
// Security keys need WebAuthn, which the sign-in here does not speak. An
|
||||||
|
// account with nothing else is refused before this form (`AccountStore`).
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Security keys are not supported here — use the code from your authenticator app.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
error_line(ui, panel);
|
||||||
|
let ready = !panel.code.trim().is_empty() && !panel.busy;
|
||||||
|
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let clicked = ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Continue"))
|
||||||
|
.inner
|
||||||
|
.clicked();
|
||||||
|
if ready && (clicked || submitted) {
|
||||||
|
panel.busy = true;
|
||||||
|
actions.push(Action::SubmitSecondFactor(panel.code.trim().to_owned()));
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
actions.push(Action::CancelSignIn);
|
||||||
|
}
|
||||||
|
busy_line(ui, panel, "checking…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mailbox_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"This account has a separate mailbox password. It unlocks your files, and is kept \
|
||||||
|
in your system keyring.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut panel.mailbox)
|
||||||
|
.password(true)
|
||||||
|
.hint_text("Mailbox password")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
error_line(ui, panel);
|
||||||
|
let ready = !panel.mailbox.is_empty() && !panel.busy;
|
||||||
|
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let clicked = ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Unlock"))
|
||||||
|
.inner
|
||||||
|
.clicked();
|
||||||
|
if ready && (clicked || submitted) {
|
||||||
|
panel.busy = true;
|
||||||
|
actions.push(Action::SubmitMailboxPassword(panel.mailbox.clone()));
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
actions.push(Action::CancelSignIn);
|
||||||
|
}
|
||||||
|
busy_line(ui, panel, "unlocking…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_in(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
panel: &mut AccountPanel,
|
||||||
|
username: &str,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"{} {username}",
|
||||||
|
egui_phosphor::regular::USER_CIRCLE
|
||||||
|
))
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
let status = match (&panel.sync_error, &panel.synced) {
|
||||||
|
(Some(error), _) => format!("Did not sync: {error}"),
|
||||||
|
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
|
||||||
|
(None, None) => {
|
||||||
|
"Library, settings and watch history sync through your Drive".to_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ui.label(ui::muted(status));
|
||||||
|
});
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(format!("{} Sign out", egui_phosphor::regular::SIGN_OUT))
|
||||||
|
.fill(theme::card_hover()),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SignOut);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} Sync now",
|
||||||
|
egui_phosphor::regular::ARROWS_CLOCKWISE
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SyncNow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Synced 3 min ago · 2 positions from other devices".
|
||||||
|
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
|
||||||
|
let mut line = format!("Synced {}", ui::ago(at, now));
|
||||||
|
if report.applied > 0 {
|
||||||
|
line.push_str(&format!(
|
||||||
|
" · {} from other devices",
|
||||||
|
ui::library::plural(report.applied, "position")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_line(ui: &mut egui::Ui, panel: &AccountPanel) {
|
||||||
|
if let Some(error) = &panel.error {
|
||||||
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn busy_line(ui: &mut egui::Ui, panel: &AccountPanel, text: &str) {
|
||||||
|
if panel.busy {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse the account's Drive and add a folder of it to the library.
|
||||||
|
pub fn browser(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
panel: &mut AccountPanel,
|
||||||
|
shares: &[pstr_core::Share],
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
if !matches!(panel.status, AccountStatus::SignedIn(_)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
ui::section(ui, "Add from your Drive");
|
||||||
|
let browser = &mut panel.browser;
|
||||||
|
if !browser.open {
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"A folder of your own Drive becomes a share like any link: crawled, browsable and \
|
||||||
|
streamed through your account.",
|
||||||
|
));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} Browse your Drive",
|
||||||
|
egui_phosphor::regular::FOLDER
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browser.open = true;
|
||||||
|
if browser.places.is_none() {
|
||||||
|
browser.loading = true;
|
||||||
|
actions.push(Action::BrowsePlaces);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
card(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if !browser.trail.is_empty()
|
||||||
|
&& ui
|
||||||
|
.button(egui_phosphor::regular::ARROW_LEFT)
|
||||||
|
.hint("Up a folder")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browser.up(actions);
|
||||||
|
}
|
||||||
|
let mut crumbs = vec!["Drive".to_owned()];
|
||||||
|
crumbs.extend(browser.trail.iter().map(|(name, _)| name.clone()));
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
crumbs.join(&format!(" {} ", egui_phosphor::regular::CARET_RIGHT)),
|
||||||
|
)
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
browser.open = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
|
if let Some(error) = &browser.error {
|
||||||
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
if browser.loading {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted("listing…"));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entered = None;
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(320.0)
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| match browser.trail.last() {
|
||||||
|
None => {
|
||||||
|
for place in browser.places.iter().flatten() {
|
||||||
|
let icon = match place.kind {
|
||||||
|
PlaceKind::MyFiles => egui_phosphor::regular::HARD_DRIVES,
|
||||||
|
PlaceKind::Device => egui_phosphor::regular::DESKTOP,
|
||||||
|
PlaceKind::SharedWithMe => egui_phosphor::regular::USERS,
|
||||||
|
};
|
||||||
|
if row(ui, icon, &place.name, None, true).clicked() {
|
||||||
|
entered = Some((place.name.clone(), place.uid.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
let entries = browser
|
||||||
|
.entries
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, entries)| entries.as_slice());
|
||||||
|
if entries.is_some_and(<[DriveEntry]>::is_empty) {
|
||||||
|
ui.label(ui::muted("This folder is empty."));
|
||||||
|
}
|
||||||
|
for entry in entries.unwrap_or_default() {
|
||||||
|
let (icon, detail) = if entry.is_folder {
|
||||||
|
(egui_phosphor::regular::FOLDER, None)
|
||||||
|
} else if entry
|
||||||
|
.media_type
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|media| media.starts_with("video/"))
|
||||||
|
{
|
||||||
|
(
|
||||||
|
egui_phosphor::regular::FILE_VIDEO,
|
||||||
|
entry.size.map(format_size),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(egui_phosphor::regular::FILE, entry.size.map(format_size))
|
||||||
|
};
|
||||||
|
if row(ui, icon, &entry.name, detail.as_deref(), entry.is_folder).clicked()
|
||||||
|
&& entry.is_folder
|
||||||
|
{
|
||||||
|
entered = Some((entry.name.clone(), entry.uid.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some((name, uid)) = entered {
|
||||||
|
browser.enter(name, uid, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((name, uid)) = browser.trail.last() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
let added = shares.iter().any(|share| {
|
||||||
|
share
|
||||||
|
.folder
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|folder| folder.uid() == *uid)
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if added {
|
||||||
|
ui.label(ui::muted("This folder is already in the library."));
|
||||||
|
} else if ui::accent_button(ui, "Add this folder").clicked() {
|
||||||
|
actions.push(Action::AddAccountFolder {
|
||||||
|
name: name.clone(),
|
||||||
|
uid: uid.clone(),
|
||||||
|
});
|
||||||
|
browser.open = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of the browser. Only folders and places are clickable.
|
||||||
|
fn row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
icon: &str,
|
||||||
|
name: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
clickable: bool,
|
||||||
|
) -> egui::Response {
|
||||||
|
let mut text = egui::text::LayoutJob::default();
|
||||||
|
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
|
||||||
|
text.append(
|
||||||
|
&format!("{icon} {name}"),
|
||||||
|
0.0,
|
||||||
|
egui::TextFormat::simple(
|
||||||
|
body.clone(),
|
||||||
|
if clickable {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if let Some(detail) = detail {
|
||||||
|
text.append(
|
||||||
|
&format!(" {detail}"),
|
||||||
|
0.0,
|
||||||
|
egui::TextFormat::simple(body, theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sense = if clickable {
|
||||||
|
egui::Sense::click()
|
||||||
|
} else {
|
||||||
|
egui::Sense::hover()
|
||||||
|
};
|
||||||
|
let response = ui.add(egui::Label::new(text).sense(sense).truncate());
|
||||||
|
if clickable && response.hovered() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_size(bytes: i64) -> String {
|
||||||
|
let bytes = bytes.max(0) as f64;
|
||||||
|
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||||
|
const MIB: f64 = 1024.0 * 1024.0;
|
||||||
|
if bytes >= GIB {
|
||||||
|
format!("{:.1} GiB", bytes / GIB)
|
||||||
|
} else {
|
||||||
|
format!("{:.0} MiB", bytes / MIB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|since| since.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sync_line_says_how_long_ago_and_what_came_in() {
|
||||||
|
let quiet = SyncReport::default();
|
||||||
|
assert_eq!(sync_line(100, &quiet, 130), "Synced just now");
|
||||||
|
assert_eq!(sync_line(100, &quiet, 400), "Synced 5 min ago");
|
||||||
|
let busy = SyncReport {
|
||||||
|
applied: 2,
|
||||||
|
..SyncReport::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
sync_line(0, &busy, 7200),
|
||||||
|
"Synced 2 h ago · 2 positions from other devices"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_out_forgets_what_was_typed_and_browsed() {
|
||||||
|
let mut panel = AccountPanel {
|
||||||
|
password: "secret".into(),
|
||||||
|
mailbox: "also secret".into(),
|
||||||
|
..AccountPanel::default()
|
||||||
|
};
|
||||||
|
panel.browser.open = true;
|
||||||
|
panel.set_status(AccountStatus::SignedOut);
|
||||||
|
assert!(panel.password.is_empty() && panel.mailbox.is_empty());
|
||||||
|
assert!(!panel.browser.open);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_listing_for_a_folder_already_left_is_dropped() {
|
||||||
|
let uid = |link: &str| {
|
||||||
|
NodeUid::new(
|
||||||
|
pstr_core::proton_sdk::ids::VolumeId::new("v".to_owned()),
|
||||||
|
pstr_core::proton_sdk::ids::LinkId::new(link.to_owned()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut browser = DriveBrowser {
|
||||||
|
trail: vec![("b".into(), uid("b"))],
|
||||||
|
loading: true,
|
||||||
|
..DriveBrowser::default()
|
||||||
|
};
|
||||||
|
browser.folder_arrived(uid("a"), Vec::new());
|
||||||
|
assert!(browser.loading && browser.entries.is_none());
|
||||||
|
browser.folder_arrived(uid("b"), Vec::new());
|
||||||
|
assert!(!browser.loading && browser.entries.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Inspectable desktop download queue.
|
//! Inspectable desktop download queue.
|
||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::{Action, Page};
|
||||||
use crate::engine::{DownloadItem, DownloadState};
|
use crate::engine::{DownloadItem, DownloadState};
|
||||||
use crate::{theme, ui};
|
use crate::{theme, ui};
|
||||||
|
|
||||||
@@ -222,12 +222,20 @@ fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DownloadState::Completed => {
|
DownloadState::Completed => {
|
||||||
|
// The point of a download is to watch it, and the library is a
|
||||||
|
// detour from here — offline most of all.
|
||||||
|
if ui::accent_button(ui, "Play").clicked() {
|
||||||
|
actions.push(Action::PlayResuming(item.target.clone()));
|
||||||
|
}
|
||||||
|
if ui.button("Open").on_hover_text("Go to its title").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(item.target.title_key.clone())));
|
||||||
|
}
|
||||||
if ui
|
if ui
|
||||||
.button("Delete")
|
.button("Delete")
|
||||||
.on_hover_text("Delete the offline copy; keep the online source")
|
.on_hover_text("Delete the offline copy; keep the online source")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::RemoveDownload(key(), false));
|
actions.push(Action::DeleteOffline(vec![key()], item.target.caption()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use pstr_core::library::{Episode, Library, Title, TitleKind};
|
|||||||
|
|
||||||
use crate::app::{Action, Page};
|
use crate::app::{Action, Page};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
use crate::ui::{self, Art};
|
use crate::ui::{self, Art};
|
||||||
|
|
||||||
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||||
@@ -161,7 +162,7 @@ fn row(
|
|||||||
)
|
)
|
||||||
.frame(false),
|
.frame(false),
|
||||||
)
|
)
|
||||||
.on_hover_text(if episode.is_watched() {
|
.hint(if episode.is_watched() {
|
||||||
"Remove from history — marks it unwatched"
|
"Remove from history — marks it unwatched"
|
||||||
} else {
|
} else {
|
||||||
"Remove from history — forgets where you stopped"
|
"Remove from history — forgets where you stopped"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! The library page: what to watch, as a wall of stills.
|
//! The library page: what to watch, as a wall of stills.
|
||||||
|
|
||||||
|
use pstr_core::appearance::Tiles;
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
|
|
||||||
use crate::app::{Action, Filter, LibraryView, Page, Sort};
|
use crate::app::{Action, Filter, LibraryView, Page, Sort};
|
||||||
@@ -16,6 +17,13 @@ pub struct Shelves<'a> {
|
|||||||
/// Whether the catalog has been read yet.
|
/// Whether the catalog has been read yet.
|
||||||
pub loaded: bool,
|
pub loaded: bool,
|
||||||
pub search: &'a str,
|
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>) {
|
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
|
||||||
@@ -24,12 +32,15 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
view,
|
view,
|
||||||
loaded,
|
loaded,
|
||||||
search,
|
search,
|
||||||
|
tiles,
|
||||||
|
crawl,
|
||||||
|
offer_metadata,
|
||||||
} = shelves;
|
} = shelves;
|
||||||
if !loaded {
|
if !loaded {
|
||||||
return placeholder(ui);
|
return placeholder(ui);
|
||||||
}
|
}
|
||||||
if library.is_empty() {
|
if library.is_empty() {
|
||||||
return empty_state(ui, actions);
|
return empty_state(ui, crawl, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pick = |indices: &[usize]| -> Vec<&Title> {
|
let pick = |indices: &[usize]| -> Vec<&Title> {
|
||||||
@@ -44,6 +55,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
if !searching && offer_metadata {
|
||||||
|
metadata_offer(ui, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
if !searching {
|
if !searching {
|
||||||
if let Some(title) = featured(library, view, art.metadata, today()) {
|
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||||
hero(ui, art, title, actions);
|
hero(ui, art, title, actions);
|
||||||
@@ -55,6 +70,16 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
continue_row(ui, art, &resumable, actions);
|
continue_row(ui, art, &resumable, actions);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
|
// Only over the whole library: under a filter, a shelf of
|
||||||
|
// titles the grid below is hiding reads as the filter not
|
||||||
|
// working.
|
||||||
|
if view.filter == Filter::All {
|
||||||
|
for (label, indices) in &view.shelves {
|
||||||
|
ui::section(ui, label);
|
||||||
|
shelf_row(ui, art, label, &pick(indices), tiles, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -86,7 +111,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid(ui, art, &matches, actions);
|
grid(ui, art, &matches, view, tiles, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +146,7 @@ fn featured<'a>(
|
|||||||
metadata
|
metadata
|
||||||
.get(&title.key)
|
.get(&title.key)
|
||||||
.and_then(|record| record.metadata.as_ref())
|
.and_then(|record| record.metadata.as_ref())
|
||||||
.is_some_and(|found| found.backdrop_url.is_some())
|
.is_some_and(|found| found.wide_art().is_some())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if pictured.is_empty() {
|
if pictured.is_empty() {
|
||||||
@@ -144,7 +169,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let radius = egui::CornerRadius::same(theme::radius::LG);
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
let picture = art.of(title);
|
let picture = art.wide(title);
|
||||||
let found = art
|
let found = art
|
||||||
.metadata
|
.metadata
|
||||||
.get(&title.key)
|
.get(&title.key)
|
||||||
@@ -252,7 +277,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
ui.add(
|
ui.add(
|
||||||
egui::Label::new(
|
egui::Label::new(
|
||||||
theme::Role::Display
|
theme::Role::Display
|
||||||
.rich(&title.name)
|
.rich(art.name(title))
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
)
|
)
|
||||||
@@ -266,24 +291,20 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The filter pills and the order, at the right of the grid's heading.
|
/// The filter pills, the order and the grouping, at the right of the grid's
|
||||||
|
/// heading.
|
||||||
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
||||||
// Right to left: the order first, so it ends up last.
|
// Right to left: the order first, so it ends up last.
|
||||||
let sort = egui::ComboBox::from_id_salt("library-sort")
|
let sort = egui::ComboBox::from_id_salt("library-sort")
|
||||||
.selected_text(match view.sort {
|
.selected_text(view.sort.label())
|
||||||
Sort::Name => "A – Z",
|
|
||||||
Sort::Recent => "Recently watched",
|
|
||||||
Sort::Year => "Newest",
|
|
||||||
})
|
|
||||||
.width(150.0)
|
.width(150.0)
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
let mut picked = None;
|
let mut picked = None;
|
||||||
for (sort, label) in [
|
for sort in Sort::ALL {
|
||||||
(Sort::Name, "A – Z"),
|
if ui
|
||||||
(Sort::Recent, "Recently watched"),
|
.selectable_label(view.sort == sort, sort.label())
|
||||||
(Sort::Year, "Newest"),
|
.clicked()
|
||||||
] {
|
{
|
||||||
if ui.selectable_label(view.sort == sort, label).clicked() {
|
|
||||||
picked = Some(sort);
|
picked = Some(sort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,23 +313,86 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
|||||||
if let Some(Some(sort)) = sort.inner {
|
if let Some(Some(sort)) = sort.inner {
|
||||||
actions.push(Action::SetShelf(view.filter, sort));
|
actions.push(Action::SetShelf(view.filter, sort));
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let mut grouped = view.grouped;
|
||||||
|
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 {
|
||||||
|
"Every title. Click to fold each franchise into one tile"
|
||||||
|
})
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetGrouped(grouped));
|
||||||
|
}
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
if let Some(filter) = ui::widgets::segmented(
|
let filters = Filter::ALL.map(|filter| (filter, filter.label()));
|
||||||
ui,
|
if let Some(filter) = ui::widgets::segmented(ui, view.filter, &filters) {
|
||||||
view.filter,
|
|
||||||
&[
|
|
||||||
(Filter::All, "All"),
|
|
||||||
(Filter::Series, "Series"),
|
|
||||||
(Filter::Films, "Films"),
|
|
||||||
(Filter::Watching, "Watching"),
|
|
||||||
(Filter::Unwatched, "Not started"),
|
|
||||||
],
|
|
||||||
) {
|
|
||||||
actions.push(Action::SetShelf(filter, view.sort));
|
actions.push(Action::SetShelf(filter, view.sort));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The top shelf: one card per part-watched title, scrolling sideways.
|
/// One shelf: titles that share something, scrolling sideways, in the grid's
|
||||||
|
/// own shape.
|
||||||
|
fn shelf_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
label: &str,
|
||||||
|
titles: &[&Title],
|
||||||
|
tiles: Tiles,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let (width, aspect) = tile_shape(tiles);
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt(("shelf", label))
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
|
for title in titles {
|
||||||
|
let picture = match tiles {
|
||||||
|
Tiles::Posters => art.poster(title),
|
||||||
|
Tiles::Stills => art.of(title),
|
||||||
|
};
|
||||||
|
let response = ui::card(
|
||||||
|
ui,
|
||||||
|
Card {
|
||||||
|
art: picture,
|
||||||
|
name: art.name(title),
|
||||||
|
subtitle: subtitle(title),
|
||||||
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
|
badge: None,
|
||||||
|
width,
|
||||||
|
aspect,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tile_menu(&response, title, false, actions);
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The minimum width and the picture's shape of a tile.
|
||||||
|
fn tile_shape(tiles: Tiles) -> (f32, f32) {
|
||||||
|
match tiles {
|
||||||
|
Tiles::Posters => (theme::POSTER_WIDTH, theme::POSTER_ASPECT),
|
||||||
|
Tiles::Stills => (theme::CARD_WIDTH, theme::CARD_ASPECT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top shelf: one card per title in progress, scrolling sideways.
|
||||||
fn continue_row(
|
fn continue_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
@@ -321,33 +405,32 @@ fn continue_row(
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
for title in titles {
|
for title in titles {
|
||||||
let Some(episode) = title.resume() else {
|
let Some(episode) = title.continue_with() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let remaining = episode
|
let remaining = match episode.resume_at() {
|
||||||
.watch
|
Some(at) => episode
|
||||||
.and_then(|watch| watch.duration_secs)
|
.watch
|
||||||
.map(|duration| {
|
.and_then(|watch| watch.duration_secs)
|
||||||
format!(
|
.map(|duration| format!("{} left", ui::format_time(duration - at)))
|
||||||
"{} left",
|
.unwrap_or_else(|| episode.label()),
|
||||||
ui::format_time(
|
None => format!("Next: {}", episode.label()),
|
||||||
duration - episode.watch.map_or(0.0, |w| w.position_secs)
|
};
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| episode.label());
|
|
||||||
|
|
||||||
|
let picture = art.of(title);
|
||||||
let response = ui::card(
|
let response = ui::card(
|
||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: art.of(title),
|
art: picture,
|
||||||
name: &title.name,
|
name: art.name(title),
|
||||||
subtitle: remaining,
|
subtitle: remaining,
|
||||||
progress: episode.progress().map(|value| value as f32),
|
progress: episode.progress().map(|value| value as f32),
|
||||||
badge: episode.numbering(),
|
badge: episode.numbering(),
|
||||||
// A shelf that scrolls sideways has no right edge
|
// A shelf that scrolls sideways has no right edge
|
||||||
// to reach, so nothing to flex to.
|
// to reach, so nothing to flex to. Always a
|
||||||
|
// still: it is a frame from where they stopped.
|
||||||
width: theme::CARD_WIDTH,
|
width: theme::CARD_WIDTH,
|
||||||
|
aspect: theme::CARD_ASPECT,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tile_menu(&response, title, true, actions);
|
tile_menu(&response, title, true, actions);
|
||||||
@@ -361,10 +444,18 @@ fn continue_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every title, wrapped to the window.
|
/// Every title, wrapped to the window.
|
||||||
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
fn grid(
|
||||||
let grid = ui::columns(ui.available_width());
|
ui: &mut egui::Ui,
|
||||||
let row_height = ui::card_height(grid.width);
|
art: &mut Art<'_>,
|
||||||
for row in titles.chunks(grid.columns) {
|
titles: &[&Title],
|
||||||
|
view: &LibraryView,
|
||||||
|
tiles: Tiles,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let (minimum, aspect) = tile_shape(tiles);
|
||||||
|
let grid = ui::columns_of(ui.available_width(), minimum);
|
||||||
|
let row_height = ui::card_height(grid.width, aspect);
|
||||||
|
for (row, chunk) in titles.chunks(grid.columns).enumerate() {
|
||||||
// A row scrolled out of view is only its height. Laying it out anyway
|
// A row scrolled out of view is only its height. Laying it out anyway
|
||||||
// costs a subtitle and an artwork lookup per card per frame, and while
|
// costs a subtitle and an artwork lookup per card per frame, and while
|
||||||
// a film plays under the transport bar a frame is every frame.
|
// a film plays under the transport bar a frame is every frame.
|
||||||
@@ -385,18 +476,32 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
// the difference times the column count is a visible drift towards
|
// the difference times the column count is a visible drift towards
|
||||||
// the left edge.
|
// the left edge.
|
||||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
for title in row {
|
for (column, title) in chunk.iter().enumerate() {
|
||||||
|
let picture = match tiles {
|
||||||
|
Tiles::Posters => art.poster(title),
|
||||||
|
Tiles::Stills => art.of(title),
|
||||||
|
};
|
||||||
|
let folded = view
|
||||||
|
.matches
|
||||||
|
.get(row * grid.columns + column)
|
||||||
|
.and_then(|index| view.folded.get(index))
|
||||||
|
.copied();
|
||||||
let response = ui::card(
|
let response = ui::card(
|
||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: art.of(title),
|
art: picture,
|
||||||
name: &title.name,
|
name: art.name(title),
|
||||||
subtitle: subtitle(title),
|
subtitle: match folded {
|
||||||
|
Some(more) => format!("{} · +{more} more", subtitle(title)),
|
||||||
|
None => subtitle(title),
|
||||||
|
},
|
||||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
// What a film is says itself in the subtitle below the
|
// What a film is says itself in the subtitle below the
|
||||||
// card; a second `Film` over the poster is noise.
|
// card; a second `Film` over the poster is noise. A
|
||||||
badge: None,
|
// folded franchise says how much is under it.
|
||||||
|
badge: folded.map(|more| format!("+{more}")),
|
||||||
width: grid.width,
|
width: grid.width,
|
||||||
|
aspect,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tile_menu(&response, title, false, actions);
|
tile_menu(&response, title, false, actions);
|
||||||
@@ -451,7 +556,7 @@ fn tile_menu(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if continuing
|
if continuing
|
||||||
&& let Some(episode) = title.resume()
|
&& let Some(episode) = title.continue_with()
|
||||||
&& ui
|
&& ui
|
||||||
.button("Remove from Continue watching")
|
.button("Remove from Continue watching")
|
||||||
.on_hover_text("Forget where you stopped in it")
|
.on_hover_text("Forget where you stopped in it")
|
||||||
@@ -517,7 +622,7 @@ fn subtitle(title: &Title) -> String {
|
|||||||
/// fraction is a flash of something false.
|
/// fraction is a flash of something false.
|
||||||
fn placeholder(ui: &mut egui::Ui) {
|
fn placeholder(ui: &mut egui::Ui) {
|
||||||
let grid = ui::columns(ui.available_width());
|
let grid = ui::columns(ui.available_width());
|
||||||
let height = ui::card_height(grid.width);
|
let height = ui::card_height(grid.width, theme::CARD_ASPECT);
|
||||||
ui::section(ui, "Library");
|
ui::section(ui, "Library");
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -540,9 +645,54 @@ fn placeholder(ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
/// Posters and synopses, offered once there is a library to put them on and
|
||||||
|
/// never turned on unasked: switching them on sends title names out.
|
||||||
|
fn metadata_offer(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::symmetric(14, 12))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.label(egui::RichText::new("Add posters and synopses?").strong());
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Turning it on in Settings sends the titles in your library — not your \
|
||||||
|
files, and nothing about what you have watched — to AniList or TMDB.",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Not now").clicked() {
|
||||||
|
actions.push(Action::DismissMetadataOffer);
|
||||||
|
}
|
||||||
|
if ui::accent_button(ui, "Open settings").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Settings));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_state(ui: &mut egui::Ui, crawl: Option<&str>, actions: &mut Vec<Action>) {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.add_space(120.0);
|
ui.add_space(120.0);
|
||||||
|
// A share just added: say it is being looked through, rather than
|
||||||
|
// offer to add the one that is already filling the page.
|
||||||
|
if let Some(crawl) = crawl {
|
||||||
|
ui.add(egui::Spinner::new().size(28.0));
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Title
|
||||||
|
.rich("Looking through the share")
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(ui::muted(format!(
|
||||||
|
"Its titles appear here once it has been listed ({crawl})."
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Title
|
theme::Role::Title
|
||||||
.rich("Nothing in the library yet")
|
.rich("Nothing in the library yet")
|
||||||
@@ -554,7 +704,7 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
|||||||
));
|
));
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
if ui::accent_button(ui, "Add a share").clicked() {
|
if ui::accent_button(ui, "Add a share").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::OpenShareForm);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,12 +130,29 @@ fn current_row(
|
|||||||
)));
|
)));
|
||||||
// Only worth offering when there is something to undo: with no
|
// Only worth offering when there is something to undo: with no
|
||||||
// row stored, "clear" would delete nothing.
|
// row stored, "clear" would delete nothing.
|
||||||
if ui
|
//
|
||||||
|
// A match chosen by hand is asked about first: it is work the
|
||||||
|
// next match run does not redo.
|
||||||
|
let asking = ui.id().with(("forget", &matcher.title_key));
|
||||||
|
if ui.data(|data| data.get_temp::<bool>(asking).unwrap_or(false)) {
|
||||||
|
ui.label(ui::muted("Forget the match you chose?"));
|
||||||
|
if ui::danger_button(ui, "Forget").clicked() {
|
||||||
|
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||||
|
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||||
|
}
|
||||||
|
if ui.button("Keep").clicked() {
|
||||||
|
ui.data_mut(|data| data.remove::<bool>(asking));
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
.button("Clear")
|
.button("Clear")
|
||||||
.on_hover_text("Forget this match and let the next match run decide again")
|
.on_hover_text("Forget this match and let the next match run decide again")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
if *manual {
|
||||||
|
ui.data_mut(|data| data.insert_temp(asking, true));
|
||||||
|
} else {
|
||||||
|
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((None, _)) => {
|
Some((None, _)) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
|
pub mod account;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
@@ -20,10 +21,11 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
|
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
|
||||||
use pstr_core::library::{Episode, Title};
|
use pstr_core::library::{Episode, Title};
|
||||||
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord};
|
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames};
|
||||||
|
|
||||||
use crate::engine::{Engine, ImageCache, thumbnail_key};
|
use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use widgets::Named;
|
||||||
|
|
||||||
/// Everything the pages need to draw one title's picture.
|
/// Everything the pages need to draw one title's picture.
|
||||||
///
|
///
|
||||||
@@ -40,8 +42,12 @@ pub struct Art<'a> {
|
|||||||
pub metadata: &'a HashMap<String, MetadataRecord>,
|
pub metadata: &'a HashMap<String, MetadataRecord>,
|
||||||
/// What they said about the episodes under those titles.
|
/// What they said about the episodes under those titles.
|
||||||
pub episodes: &'a HashMap<String, EpisodeGuide>,
|
pub episodes: &'a HashMap<String, EpisodeGuide>,
|
||||||
|
/// Which name a title goes by.
|
||||||
|
pub names: TitleNames,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub use pstr_core::browse::display_name as title_name;
|
||||||
|
|
||||||
impl Art<'_> {
|
impl Art<'_> {
|
||||||
/// What the provider says about one file of a title, matched on the
|
/// What the provider says about one file of a title, matched on the
|
||||||
/// numbering its name states.
|
/// numbering its name states.
|
||||||
@@ -74,6 +80,58 @@ impl Art<'_> {
|
|||||||
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name to show for `title`. See [`title_name`].
|
||||||
|
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
|
||||||
|
title_name(title, self.metadata, self.names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 2:3 cover for `title`, for the poster grid; whatever [`Art::of`]
|
||||||
|
/// has when there is none.
|
||||||
|
pub fn poster(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
|
||||||
|
let url = self
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|metadata| metadata.poster_url.clone());
|
||||||
|
if let Some(url) = url {
|
||||||
|
let key = poster_key(&title.key);
|
||||||
|
let engine = self.engine;
|
||||||
|
if let Some(texture) = self
|
||||||
|
.posters
|
||||||
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
|
{
|
||||||
|
return Some((texture, ArtShape::Portrait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.of(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Art for a strip across the whole window, at full size: fanart, else
|
||||||
|
/// AniList's banner, else whatever [`Art::of`] has.
|
||||||
|
///
|
||||||
|
/// Its own texture rather than the tile's: a 640 px tile picture
|
||||||
|
/// stretched to a 1600 px hero is soft, and a 1920 px one in every tile
|
||||||
|
/// would be most of the texture budget.
|
||||||
|
pub fn wide(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
|
||||||
|
let url = self
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|metadata| metadata.wide_art())
|
||||||
|
.map(str::to_owned);
|
||||||
|
if let Some(url) = url {
|
||||||
|
let key = wide_key(&title.key);
|
||||||
|
let engine = self.engine;
|
||||||
|
if let Some(texture) = self
|
||||||
|
.posters
|
||||||
|
.texture(&key, || engine.request_art(key.clone(), url, WIDE_MAX_EDGE))
|
||||||
|
{
|
||||||
|
return Some((texture, ArtShape::Landscape));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.of(title)
|
||||||
|
}
|
||||||
|
|
||||||
/// The picture for a title, and how to fit it.
|
/// The picture for a title, and how to fit it.
|
||||||
///
|
///
|
||||||
/// Provider artwork first, then Proton's still, then nothing. That order is
|
/// Provider artwork first, then Proton's still, then nothing. That order is
|
||||||
@@ -104,6 +162,30 @@ impl Art<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
|
||||||
|
/// title key itself.
|
||||||
|
pub fn poster_key(title_key: &str) -> String {
|
||||||
|
format!("poster:{title_key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The texture key of a title's full-size wide art.
|
||||||
|
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.
|
/// `1:03:47`, or `4:12` for anything under an hour.
|
||||||
pub fn format_time(seconds: f64) -> String {
|
pub fn format_time(seconds: f64) -> String {
|
||||||
if !seconds.is_finite() || seconds < 0.0 {
|
if !seconds.is_finite() || seconds < 0.0 {
|
||||||
@@ -243,7 +325,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
|||||||
);
|
);
|
||||||
if response
|
if response
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
.on_hover_text("Clear (Esc)")
|
.hint("Clear (Esc)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
search.clear();
|
search.clear();
|
||||||
@@ -306,6 +388,19 @@ pub enum Answer {
|
|||||||
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
||||||
/// the click.
|
/// the click.
|
||||||
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
||||||
|
confirm_with(ctx, id, heading, body, verb, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`confirm`], for a question whose yes loses nothing — a large download —
|
||||||
|
/// and so is the accent rather than the danger colour.
|
||||||
|
pub fn confirm_with(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
id: &str,
|
||||||
|
heading: &str,
|
||||||
|
body: &str,
|
||||||
|
verb: &str,
|
||||||
|
destroys: bool,
|
||||||
|
) -> Answer {
|
||||||
let mut answer = Answer::Pending;
|
let mut answer = Answer::Pending;
|
||||||
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||||
.frame(
|
.frame(
|
||||||
@@ -326,7 +421,12 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
|
|||||||
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if danger_button(ui, verb).clicked() {
|
let yes = if destroys {
|
||||||
|
danger_button(ui, verb)
|
||||||
|
} else {
|
||||||
|
accent_button(ui, verb)
|
||||||
|
};
|
||||||
|
if yes.clicked() {
|
||||||
answer = Answer::Confirmed;
|
answer = Answer::Confirmed;
|
||||||
}
|
}
|
||||||
if ui.button("Cancel").clicked() {
|
if ui.button("Cancel").clicked() {
|
||||||
@@ -430,6 +530,7 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||||
|
let response = response.named(text);
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -492,6 +593,9 @@ pub struct Card<'a> {
|
|||||||
/// sideways-scrolling shelf has no edge to reach and passes
|
/// sideways-scrolling shelf has no edge to reach and passes
|
||||||
/// [`theme::CARD_WIDTH`].
|
/// [`theme::CARD_WIDTH`].
|
||||||
pub width: f32,
|
pub width: f32,
|
||||||
|
/// The picture's height over its width: [`theme::CARD_ASPECT`] for a
|
||||||
|
/// still, [`theme::POSTER_ASPECT`] for a cover.
|
||||||
|
pub aspect: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
|
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
|
||||||
@@ -499,10 +603,10 @@ pub struct Card<'a> {
|
|||||||
/// different height by title length reads as broken.
|
/// different height by title length reads as broken.
|
||||||
const CARD_TEXT_HEIGHT: f32 = 54.0;
|
const CARD_TEXT_HEIGHT: f32 = 54.0;
|
||||||
|
|
||||||
/// How tall [`card`] draws a tile of this width, so a grid can step over rows
|
/// How tall [`card`] draws a tile of this width and shape, so a grid can step
|
||||||
/// it does not draw.
|
/// over rows it does not draw.
|
||||||
pub fn card_height(width: f32) -> f32 {
|
pub fn card_height(width: f32, aspect: f32) -> f32 {
|
||||||
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT
|
(width * aspect).round() + CARD_TEXT_HEIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one tile and report whether it was clicked.
|
/// Draw one tile and report whether it was clicked.
|
||||||
@@ -515,12 +619,20 @@ pub fn card_height(width: f32) -> f32 {
|
|||||||
/// reads as deliberate in a way a stretched poster does not.
|
/// reads as deliberate in a way a stretched poster does not.
|
||||||
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||||
let width = card.width;
|
let width = card.width;
|
||||||
let image_height = (width * theme::CARD_ASPECT).round();
|
let image_height = (width * card.aspect).round();
|
||||||
|
// A cover fitted into a still-shaped tile is drawn whole; into a tile its
|
||||||
|
// own shape, it fills it like anything else.
|
||||||
|
let fit = card.aspect < 1.0;
|
||||||
let text_height = CARD_TEXT_HEIGHT;
|
let text_height = CARD_TEXT_HEIGHT;
|
||||||
let radius = CornerRadius::same(theme::radius::MD);
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
|
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||||
|
let response = if card.subtitle.is_empty() {
|
||||||
|
response.named(card.name)
|
||||||
|
} else {
|
||||||
|
response.named(format!("{}, {}", card.name, card.subtitle))
|
||||||
|
};
|
||||||
// Reached with the arrow keys or Tab: bring it into view, before the
|
// Reached with the arrow keys or Tab: bring it into view, before the
|
||||||
// early return below would skip a tile that is just off screen.
|
// early return below would skip a tile that is just off screen.
|
||||||
if response.gained_focus() {
|
if response.gained_focus() {
|
||||||
@@ -574,7 +686,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
|
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Some((texture, ArtShape::Portrait)) => {
|
Some((texture, ArtShape::Portrait)) if fit => {
|
||||||
painter.add(
|
painter.add(
|
||||||
egui::epaint::RectShape::filled(
|
egui::epaint::RectShape::filled(
|
||||||
contain_rect(texture.size_vec2(), image_rect),
|
contain_rect(texture.size_vec2(), image_rect),
|
||||||
@@ -587,6 +699,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Some((texture, ArtShape::Portrait)) => {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(image_rect, radius, ink).with_texture(
|
||||||
|
texture.id(),
|
||||||
|
cover_uv(texture.size_vec2(), image_rect.size()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,9 +897,13 @@ pub struct Grid {
|
|||||||
/// dead space at the right of every row, and on a wide window that reads as a
|
/// dead space at the right of every row, and on a wide window that reads as a
|
||||||
/// layout that failed to line up rather than as a margin.
|
/// layout that failed to line up rather than as a margin.
|
||||||
pub fn columns(available: f32) -> Grid {
|
pub fn columns(available: f32) -> Grid {
|
||||||
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor()
|
columns_of(available, theme::CARD_WIDTH)
|
||||||
as usize)
|
}
|
||||||
.max(1);
|
|
||||||
|
/// [`columns`], for cards at least `minimum` wide.
|
||||||
|
pub fn columns_of(available: f32, minimum: f32) -> Grid {
|
||||||
|
let columns =
|
||||||
|
(((available + theme::CARD_GAP) / (minimum + theme::CARD_GAP)).floor() as usize).max(1);
|
||||||
// The gaps come out of the width before it is split. Floored, because a
|
// The gaps come out of the width before it is split. Floored, because a
|
||||||
// fraction of a point times four columns is enough to push the last card
|
// fraction of a point times four columns is enough to push the last card
|
||||||
// onto a row of its own. A window too narrow for even one full card gets a
|
// onto a row of its own. A window too narrow for even one full card gets a
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
|
|||||||
use crate::app::{Action, Adjacent};
|
use crate::app::{Action, Adjacent};
|
||||||
use crate::playback::{Command, Playback};
|
use crate::playback::{Command, Playback};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
use crate::ui::{self, transport::Neighbours};
|
use crate::ui::{self, transport::Neighbours};
|
||||||
|
|
||||||
/// How long the controls stay up after the pointer stops moving.
|
/// How long the controls stay up after the pointer stops moving.
|
||||||
@@ -139,6 +140,9 @@ pub struct UpNextCard {
|
|||||||
pub caption: String,
|
pub caption: String,
|
||||||
/// Its still, once there is one.
|
/// Its still, once there is one.
|
||||||
pub still: Option<egui::TextureHandle>,
|
pub still: Option<egui::TextureHandle>,
|
||||||
|
/// Counting down the end of a file with no credits chapter, where the
|
||||||
|
/// second button cancels the next episode rather than deferring it.
|
||||||
|
pub tail: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
@@ -185,16 +189,28 @@ impl Overlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the player page shows while a file opens and there is no picture yet.
|
||||||
|
pub struct Waiting<'a> {
|
||||||
|
/// The episode being opened.
|
||||||
|
pub name: &'a str,
|
||||||
|
/// The title it belongs to, as the library names it.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// The title's widest art, dimmed behind the words.
|
||||||
|
pub art: Option<egui::TextureHandle>,
|
||||||
|
/// Whether one episode is following another, rather than a first click.
|
||||||
|
pub next: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Draw the picture and its controls.
|
/// Draw the picture and its controls.
|
||||||
///
|
///
|
||||||
/// `playback` is `None` in the gap between one episode ending and the next
|
/// `playback` is `None` until the file has opened — on a first click, and in
|
||||||
/// having opened — the page stays, because that is where the viewer means to
|
/// the gap between one episode ending and the next — and `opening` is what the
|
||||||
/// be, and `opening` is what it says while they wait.
|
/// page says meanwhile.
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
frame: &mut eframe::Frame,
|
frame: &mut eframe::Frame,
|
||||||
playback: Option<&mut Playback>,
|
playback: Option<&mut Playback>,
|
||||||
opening: Option<&str>,
|
opening: Option<Waiting<'_>>,
|
||||||
chrome_state: Chrome<'_>,
|
chrome_state: Chrome<'_>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -372,26 +388,60 @@ fn stalled(ui: &mut egui::Ui, rect: Rect) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The screen between two episodes: one has ended and the next is opening.
|
/// The screen while a file opens: after a click, or between one episode and
|
||||||
|
/// the next.
|
||||||
///
|
///
|
||||||
/// It keeps a way out, because opening a file over a public link can take
|
/// It keeps a way out, because opening a file over a public link can take
|
||||||
/// seconds and a black screen with no controls is indistinguishable from a
|
/// seconds and a black screen with no controls is indistinguishable from a
|
||||||
/// hang.
|
/// hang.
|
||||||
fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut Vec<Action>) {
|
fn between(ui: &mut egui::Ui, waiting: Option<Waiting<'_>>, rect: Rect, actions: &mut Vec<Action>) {
|
||||||
|
let Waiting {
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
art,
|
||||||
|
next,
|
||||||
|
} = waiting.unwrap_or(Waiting {
|
||||||
|
name: "opening…",
|
||||||
|
title: None,
|
||||||
|
art: None,
|
||||||
|
next: true,
|
||||||
|
});
|
||||||
|
if let Some(art) = &art {
|
||||||
|
// Cover rather than fit: bars around a dimmed backdrop look like a
|
||||||
|
// mistake, and its edges are not what anyone is looking at.
|
||||||
|
let size = art.size_vec2();
|
||||||
|
let scale = (rect.width() / size.x).max(rect.height() / size.y);
|
||||||
|
let shown = size * scale;
|
||||||
|
let uv = Rect::from_center_size(
|
||||||
|
egui::pos2(0.5, 0.5),
|
||||||
|
Vec2::new(rect.width() / shown.x, rect.height() / shown.y),
|
||||||
|
);
|
||||||
|
ui.painter()
|
||||||
|
.image(art.id(), rect, uv, Color32::from_white_alpha(70));
|
||||||
|
}
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.center() - Vec2::new(0.0, 16.0),
|
rect.center() - Vec2::new(0.0, 16.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
"Up next",
|
match (&title, next) {
|
||||||
|
(_, true) => "Up next",
|
||||||
|
(Some(title), false) => title.as_str(),
|
||||||
|
(None, false) => "Opening",
|
||||||
|
},
|
||||||
theme::Role::Body.font(),
|
theme::Role::Body.font(),
|
||||||
theme::muted(),
|
theme::muted(),
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.center() + Vec2::new(0.0, 12.0),
|
rect.center() + Vec2::new(0.0, 12.0),
|
||||||
Align2::CENTER_CENTER,
|
Align2::CENTER_CENTER,
|
||||||
opening.unwrap_or("opening…"),
|
name,
|
||||||
theme::Role::Title.font(),
|
theme::Role::Title.font(),
|
||||||
theme::text(),
|
theme::text(),
|
||||||
);
|
);
|
||||||
|
let spinner = Rect::from_center_size(rect.center() + Vec2::new(0.0, 56.0), Vec2::splat(24.0));
|
||||||
|
ui.put(
|
||||||
|
spinner,
|
||||||
|
egui::Spinner::new().size(24.0).color(Color32::WHITE),
|
||||||
|
);
|
||||||
|
|
||||||
ui.scope_builder(
|
ui.scope_builder(
|
||||||
egui::UiBuilder::new()
|
egui::UiBuilder::new()
|
||||||
@@ -404,8 +454,6 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
|||||||
if ui.button("Back").clicked() {
|
if ui.button("Back").clicked() {
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::M);
|
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -539,7 +587,7 @@ fn up_next_card(
|
|||||||
theme::Role::Body.font(),
|
theme::Role::Body.font(),
|
||||||
Color32::WHITE,
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
if response.on_hover_text("Play now").clicked() {
|
if response.hint("Play now").clicked() {
|
||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,11 +614,15 @@ fn up_next_card(
|
|||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
if ui
|
let (label, hint) = if card.tail {
|
||||||
.button("Watch till the end")
|
("Cancel", "Stay here — the next episode will not start")
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
} else {
|
||||||
.clicked()
|
(
|
||||||
{
|
"Watch till the end",
|
||||||
|
"Play this one out — nothing will be skipped",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ui.button(label).on_hover_text(hint).clicked() {
|
||||||
actions.push(Action::WatchToEnd);
|
actions.push(Action::WatchToEnd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -695,7 +747,7 @@ fn chrome(
|
|||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text(if fullscreen {
|
.hint(if fullscreen {
|
||||||
"Leave fullscreen (F)"
|
"Leave fullscreen (F)"
|
||||||
} else {
|
} else {
|
||||||
"Fullscreen (F)"
|
"Fullscreen (F)"
|
||||||
@@ -727,12 +779,31 @@ fn chrome(
|
|||||||
.corner_radius(CornerRadius::same(theme::radius::MD))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Episodes (E)")
|
.hint("Episodes (E)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleEpisodes);
|
actions.push(Action::ToggleEpisodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Esc leaves the page and keeps playing; this is the one-step
|
||||||
|
// way to be done with the episode.
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::STOP)
|
||||||
|
.color(Color32::WHITE),
|
||||||
|
)
|
||||||
|
.fill(Color32::from_black_alpha(160))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
|
)
|
||||||
|
.hint("Stop")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Player(Command::Stop));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -834,7 +905,7 @@ fn episode_drawer(
|
|||||||
)
|
)
|
||||||
.frame(false),
|
.frame(false),
|
||||||
)
|
)
|
||||||
.on_hover_text("Close (E)")
|
.hint("Close (E)")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleEpisodes);
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
//! that manages Proton Drive links, and a page that is half link management and
|
//! that manages Proton Drive links, and a page that is half link management and
|
||||||
//! half preferences is two pages.
|
//! half preferences is two pages.
|
||||||
|
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||||
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
|
use pstr_player::LANGUAGES;
|
||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::Action;
|
||||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs, TEXT_SIZE_CHOICES};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// The settings that are a single value rather than a form.
|
/// The settings that are a single value rather than a form.
|
||||||
///
|
///
|
||||||
@@ -19,9 +22,11 @@ use crate::ui;
|
|||||||
/// they are read-only, and passing them one by one is what pushed this
|
/// they are read-only, and passing them one by one is what pushed this
|
||||||
/// function past the argument count anybody can read.
|
/// function past the argument count anybody can read.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Prefs {
|
pub struct Prefs<'a> {
|
||||||
pub autoplay: bool,
|
pub autoplay: bool,
|
||||||
pub auto_skip: bool,
|
pub auto_skip: bool,
|
||||||
|
/// The languages, read from here.
|
||||||
|
pub playback: &'a PlaybackPrefs,
|
||||||
pub appearance: Appearance,
|
pub appearance: Appearance,
|
||||||
pub desktop: DesktopPrefs,
|
pub desktop: DesktopPrefs,
|
||||||
/// What the streaming cache holds, once the shares are open.
|
/// What the streaming cache holds, once the shares are open.
|
||||||
@@ -35,7 +40,7 @@ const COLUMN: f32 = 760.0;
|
|||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
settings: &mut MetadataConfig,
|
settings: &mut MetadataConfig,
|
||||||
prefs: Prefs,
|
prefs: Prefs<'_>,
|
||||||
api_key: &mut String,
|
api_key: &mut String,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -51,7 +56,7 @@ pub fn show(
|
|||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.set_width(width);
|
ui.set_width(width);
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
appearance_form(ui, prefs.appearance, actions);
|
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
||||||
playback_form(ui, prefs, actions);
|
playback_form(ui, prefs, actions);
|
||||||
storage_form(ui, prefs, actions);
|
storage_form(ui, prefs, actions);
|
||||||
metadata_form(ui, settings, api_key, actions);
|
metadata_form(ui, settings, api_key, actions);
|
||||||
@@ -66,7 +71,12 @@ pub fn show(
|
|||||||
/// Every control here repaints the whole window the moment it is clicked —
|
/// Every control here repaints the whole window the moment it is clicked —
|
||||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||||
/// only changed a small square would be asking someone to imagine the result.
|
/// only changed a small square would be asking someone to imagine the result.
|
||||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
fn appearance_form(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
appearance: Appearance,
|
||||||
|
desktop: DesktopPrefs,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||||
@@ -75,6 +85,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
|||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
for flavor in Flavor::ALL {
|
for flavor in Flavor::ALL {
|
||||||
if palette_card(ui, appearance, flavor)
|
if palette_card(ui, appearance, flavor)
|
||||||
|
.named(flavor.label())
|
||||||
.on_hover_text(flavor.description())
|
.on_hover_text(flavor.description())
|
||||||
.clicked()
|
.clicked()
|
||||||
&& appearance.flavor != flavor
|
&& appearance.flavor != flavor
|
||||||
@@ -102,6 +113,62 @@ 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",
|
||||||
|
Some("Covers, or 16:9 stills. Covers are the art every matched title has."),
|
||||||
|
|ui| {
|
||||||
|
let choices: Vec<(Tiles, &str)> = Tiles::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|tiles| (*tiles, tiles.label()))
|
||||||
|
.collect();
|
||||||
|
if let Some(tiles) = ui::widgets::segmented(ui, appearance.tiles, &choices) {
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
tiles,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Title names",
|
||||||
|
Some("The name a matched title goes by. Its other names still find it in search."),
|
||||||
|
|ui| {
|
||||||
|
let choices: Vec<(TitleNames, &str)> = TitleNames::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|names| (*names, names.label()))
|
||||||
|
.collect();
|
||||||
|
if let Some(names) = ui::widgets::segmented(ui, appearance.names, &choices) {
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
names,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let mut gradients = appearance.gradients;
|
let mut gradients = appearance.gradients;
|
||||||
ui::widgets::settings_row(
|
ui::widgets::settings_row(
|
||||||
ui,
|
ui,
|
||||||
@@ -230,22 +297,24 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
.on_hover_text(accent.label())
|
.hint(accent.label())
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The playback settings that are not per-file.
|
/// The playback settings that are not per-file.
|
||||||
///
|
///
|
||||||
/// Volume, audio language, subtitle language and speed are all set from the
|
/// Volume and speed are set from the player itself, where a viewer can hear
|
||||||
/// player itself, where a viewer can hear the result.
|
/// the result. The languages are here as well as there: what a first episode
|
||||||
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
/// opens with is decided before there is a player to choose from.
|
||||||
|
fn playback_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||||
ui::widgets::settings_group(
|
ui::widgets::settings_group(
|
||||||
ui,
|
ui,
|
||||||
"Playback",
|
"Playback",
|
||||||
Some(
|
Some(
|
||||||
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
|
"Volume and speed are set from the player. A language chosen in the player for one show is kept for that show.",
|
||||||
),
|
),
|
||||||
|ui| {
|
|ui| {
|
||||||
|
languages(ui, prefs.playback, actions);
|
||||||
let mut autoplay = prefs.autoplay;
|
let mut autoplay = prefs.autoplay;
|
||||||
ui::widgets::settings_row(
|
ui::widgets::settings_row(
|
||||||
ui,
|
ui,
|
||||||
@@ -287,8 +356,80 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The audio and subtitle languages every file opens with, until a show has a
|
||||||
|
/// choice of its own.
|
||||||
|
fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Action>) {
|
||||||
|
let name = |tag: &Option<String>| match tag {
|
||||||
|
Some(tag) => pstr_player::language_name(tag),
|
||||||
|
None => "The file's default".to_owned(),
|
||||||
|
};
|
||||||
|
let audio = playback.audio_language.clone();
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Audio language",
|
||||||
|
Some("Played when a file has it."),
|
||||||
|
|ui| {
|
||||||
|
egui::ComboBox::from_id_salt("audio-language")
|
||||||
|
.selected_text(name(&audio))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for choice in
|
||||||
|
std::iter::once(None).chain(LANGUAGES.map(|tag| Some(tag.to_owned())))
|
||||||
|
{
|
||||||
|
if ui
|
||||||
|
.selectable_label(audio == choice, name(&choice))
|
||||||
|
.clicked()
|
||||||
|
&& audio != choice
|
||||||
|
{
|
||||||
|
actions.push(Action::SetLanguages {
|
||||||
|
audio: choice,
|
||||||
|
subtitles: playback.subtitles,
|
||||||
|
subtitle: playback.subtitle_language.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Off, the file's default, or a language: "off" is a decision of its
|
||||||
|
// own, which no language expresses.
|
||||||
|
let subtitle = playback
|
||||||
|
.subtitles
|
||||||
|
.then(|| playback.subtitle_language.clone());
|
||||||
|
let subtitle_name = |choice: &Option<Option<String>>| match choice {
|
||||||
|
None => "Off".to_owned(),
|
||||||
|
Some(tag) => name(tag),
|
||||||
|
};
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Subtitles",
|
||||||
|
Some("Shown when a file has them in this language."),
|
||||||
|
|ui| {
|
||||||
|
egui::ComboBox::from_id_salt("subtitle-language")
|
||||||
|
.selected_text(subtitle_name(&subtitle))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
let choices = [None, Some(None)]
|
||||||
|
.into_iter()
|
||||||
|
.chain(LANGUAGES.map(|tag| Some(Some(tag.to_owned()))));
|
||||||
|
for choice in choices {
|
||||||
|
if ui
|
||||||
|
.selectable_label(subtitle == choice, subtitle_name(&choice))
|
||||||
|
.clicked()
|
||||||
|
&& subtitle != choice
|
||||||
|
{
|
||||||
|
actions.push(Action::SetLanguages {
|
||||||
|
audio: playback.audio_language.clone(),
|
||||||
|
subtitles: choice.is_some(),
|
||||||
|
subtitle: choice.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The streaming cache: how big it may get, and a way to empty it.
|
/// The streaming cache: how big it may get, and a way to empty it.
|
||||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||||
ui::widgets::settings_group(
|
ui::widgets::settings_group(
|
||||||
ui,
|
ui,
|
||||||
"Storage",
|
"Storage",
|
||||||
@@ -369,7 +510,9 @@ fn metadata_form(
|
|||||||
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
||||||
without this every tile is a pair of initials. Turning it on sends the titles \
|
without this every tile is a pair of initials. Turning it on sends the titles \
|
||||||
in your library — not your files, and nothing about what you have watched — to \
|
in your library — not your files, and nothing about what you have watched — to \
|
||||||
the provider you choose, over HTTPS, each time a new one appears.",
|
the provider you choose, over HTTPS, each time a new one appears. With AniList, \
|
||||||
|
the ids it matched — not the titles — then go to ani.zip for episode names and \
|
||||||
|
wide artwork.",
|
||||||
));
|
));
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
//! below is the only place a link is ever visible, and only while it is being
|
//! below is the only place a link is ever visible, and only while it is being
|
||||||
//! typed.
|
//! typed.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use pstr_core::Share;
|
use pstr_core::Share;
|
||||||
|
use pstr_core::catalog::CrawlRecord;
|
||||||
use pstr_core::library::Library;
|
use pstr_core::library::Library;
|
||||||
|
|
||||||
use crate::app::{Action, ShareForm};
|
use crate::app::{Action, ShareForm};
|
||||||
@@ -16,8 +19,10 @@ use crate::ui;
|
|||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
shares: &[Share],
|
shares: &[Share],
|
||||||
|
crawls: &HashMap<String, CrawlRecord>,
|
||||||
library: &Library,
|
library: &Library,
|
||||||
form: &mut ShareForm,
|
form: &mut ShareForm,
|
||||||
|
account: &mut ui::account::AccountPanel,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
@@ -35,15 +40,26 @@ pub fn show(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|title| title.share_ids.contains(&share.id))
|
.filter(|title| title.share_ids.contains(&share.id))
|
||||||
.count();
|
.count();
|
||||||
share_row(ui, share, titles, actions);
|
share_row(ui, share, titles, crawls.get(&share.id), form, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(theme::space::XXL);
|
ui.add_space(theme::space::XXL);
|
||||||
add_form(ui, shares, form, actions);
|
add_form(ui, shares, form, actions);
|
||||||
|
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
ui::account::section(ui, account, actions);
|
||||||
|
ui::account::browser(ui, account, shares, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
fn share_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
share: &Share,
|
||||||
|
titles: usize,
|
||||||
|
crawl: Option<&CrawlRecord>,
|
||||||
|
form: &mut ShareForm,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
@@ -56,10 +72,29 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
|||||||
// What is in it, rather than what it is called inside the
|
// What is in it, rather than what it is called inside the
|
||||||
// app. The id is still a hover away for a bug report.
|
// app. The id is still a hover away for a bug report.
|
||||||
let mut detail = ui::library::plural(titles, "title");
|
let mut detail = ui::library::plural(titles, "title");
|
||||||
|
if share.folder.is_some() {
|
||||||
|
detail.push_str(" · from your Drive");
|
||||||
|
}
|
||||||
if share.has_custom_password {
|
if share.has_custom_password {
|
||||||
detail.push_str(" · password protected");
|
detail.push_str(" · password protected");
|
||||||
}
|
}
|
||||||
|
match crawl {
|
||||||
|
Some(crawl) if crawl.crawled_at > 0 => {
|
||||||
|
detail.push_str(" · crawled ");
|
||||||
|
detail.push_str(&ui::ago(crawl.crawled_at, crate::engine::unix_now()));
|
||||||
|
}
|
||||||
|
_ => detail.push_str(" · not crawled yet"),
|
||||||
|
}
|
||||||
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||||
|
// Why the last look failed, until one succeeds: a link
|
||||||
|
// revoked upstream otherwise just stops growing.
|
||||||
|
if let Some(error) = crawl.and_then(|crawl| crawl.error.as_deref()) {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich(format!("Last crawl failed: {error}"))
|
||||||
|
.color(theme::danger()),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
@@ -73,12 +108,121 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
|
|||||||
if ui.button("Crawl").clicked() {
|
if ui.button("Crawl").clicked() {
|
||||||
actions.push(Action::Crawl(Some(share.id.clone())));
|
actions.push(Action::Crawl(Some(share.id.clone())));
|
||||||
}
|
}
|
||||||
|
if ui.button("Rename").clicked() {
|
||||||
|
form.renaming = Some(share.id.clone());
|
||||||
|
form.rename_to = share.name.clone();
|
||||||
|
}
|
||||||
|
// Not for a folder of the account, which has no link.
|
||||||
|
if share.folder.is_none()
|
||||||
|
&& ui
|
||||||
|
.button("Re-enter link")
|
||||||
|
.on_hover_text(
|
||||||
|
"For a keyring that lost the link, or a password that changed. \
|
||||||
|
The library, history and downloads stay.",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
form.repairing = Some(share.id.clone());
|
||||||
|
form.repair_url.clear();
|
||||||
|
form.repair_password.clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if form.repairing.as_deref() == Some(share.id.as_str()) {
|
||||||
|
repair_form(ui, share, form, actions);
|
||||||
|
}
|
||||||
|
if form.renaming.as_deref() == Some(share.id.as_str()) {
|
||||||
|
rename_form(ui, share, form, actions);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new name for a share, in its own row. Enter saves, as everywhere else.
|
||||||
|
fn rename_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let field = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut form.rename_to)
|
||||||
|
.hint_text("Name")
|
||||||
|
.desired_width(320.0),
|
||||||
|
);
|
||||||
|
let ready = !form.rename_to.trim().is_empty();
|
||||||
|
let entered = field.lost_focus() && ui_enter_pressed(ui.ctx());
|
||||||
|
if ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||||
|
.inner
|
||||||
|
.clicked()
|
||||||
|
|| (ready && entered)
|
||||||
|
{
|
||||||
|
actions.push(Action::RenameShare {
|
||||||
|
id: share.id.clone(),
|
||||||
|
name: form.rename_to.trim().to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
form.renaming = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same link again, and a password if it has one, for a share already in
|
||||||
|
/// the library.
|
||||||
|
fn repair_form(ui: &mut egui::Ui, share: &Share, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut form.repair_url)
|
||||||
|
.password(true)
|
||||||
|
.hint_text("The share link, including everything after the #")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut form.repair_password)
|
||||||
|
.password(true)
|
||||||
|
.hint_text("Link password, if it has one")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let problem = (!form.repair_url.trim().is_empty())
|
||||||
|
.then(|| pstr_core::shares::share_token(form.repair_url.trim()).err())
|
||||||
|
.flatten();
|
||||||
|
if let Some(problem) = &problem {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich(problem.to_string())
|
||||||
|
.color(theme::danger()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let ready = !form.repair_url.trim().is_empty() && problem.is_none();
|
||||||
|
if ui
|
||||||
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Save"))
|
||||||
|
.inner
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RepairShare {
|
||||||
|
id: share.id.clone(),
|
||||||
|
url: form.repair_url.trim().to_owned(),
|
||||||
|
password: Some(form.repair_password.clone())
|
||||||
|
.filter(|password| !password.is_empty()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
form.repairing = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The add form's link field, which the library's empty state focuses.
|
||||||
|
pub fn link_field_id() -> egui::Id {
|
||||||
|
egui::Id::new("share-link")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_enter_pressed(ctx: &egui::Context) -> bool {
|
||||||
|
ctx.input(|input| input.key_pressed(egui::Key::Enter))
|
||||||
|
}
|
||||||
|
|
||||||
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Add a share");
|
ui::section(ui, "Add a share");
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
@@ -94,24 +238,28 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
|
|
||||||
ui.label(ui::muted("Name"));
|
// Enter in any field of the form submits it, as the sign-in
|
||||||
ui.add(
|
// form's do.
|
||||||
egui::TextEdit::singleline(&mut form.name)
|
let mut entered = false;
|
||||||
.hint_text("Anime")
|
let mut submitted_from = |response: egui::Response| {
|
||||||
.desired_width(f32::INFINITY),
|
entered |= response.lost_focus() && ui_enter_pressed(&response.ctx);
|
||||||
);
|
};
|
||||||
ui.add_space(theme::space::M);
|
|
||||||
|
|
||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let paste = 60.0;
|
let paste = 60.0;
|
||||||
let field = egui::Id::new("share-link");
|
let field = link_field_id();
|
||||||
ui.add(
|
if std::mem::take(&mut form.focus) {
|
||||||
egui::TextEdit::singleline(&mut form.url)
|
ui.memory_mut(|memory| memory.request_focus(field));
|
||||||
.id(field)
|
}
|
||||||
.password(true)
|
submitted_from(
|
||||||
.hint_text("https://drive.proton.me/urls/…#…")
|
ui.add(
|
||||||
.desired_width(ui.available_width() - paste),
|
egui::TextEdit::singleline(&mut form.url)
|
||||||
|
.id(field)
|
||||||
|
.password(true)
|
||||||
|
.hint_text("https://drive.proton.me/urls/…#…")
|
||||||
|
.desired_width(ui.available_width() - paste),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// The link is masked, so pasting is the only sensible way in —
|
// The link is masked, so pasting is the only sensible way in —
|
||||||
// and a button says that more plainly than the field does.
|
// and a button says that more plainly than the field does.
|
||||||
@@ -136,20 +284,31 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
}
|
}
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
|
ui.label(ui::muted("Name (optional)"));
|
||||||
|
submitted_from(
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut form.name)
|
||||||
|
.hint_text("The shared folder's own name")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||||
if form.has_password {
|
if form.has_password {
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(
|
submitted_from(
|
||||||
egui::TextEdit::singleline(&mut form.password)
|
ui.add(
|
||||||
.password(true)
|
egui::TextEdit::singleline(&mut form.password)
|
||||||
.hint_text("Link password")
|
.password(true)
|
||||||
.desired_width(f32::INFINITY),
|
.hint_text("Link password")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
let ready = !form.name.trim().is_empty()
|
let ready = !form.url.trim().is_empty()
|
||||||
&& !form.url.trim().is_empty()
|
|
||||||
&& problem.is_none()
|
&& problem.is_none()
|
||||||
&& (!form.has_password || !form.password.is_empty())
|
&& (!form.has_password || !form.password.is_empty())
|
||||||
&& !form.sending;
|
&& !form.sending;
|
||||||
@@ -159,11 +318,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
let clicked = ui
|
||||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add share"))
|
||||||
.inner
|
.inner
|
||||||
.clicked()
|
.clicked();
|
||||||
{
|
if clicked || (ready && entered) {
|
||||||
actions.push(Action::AddShare {
|
actions.push(Action::AddShare {
|
||||||
name: form.name.trim().to_string(),
|
name: form.name.trim().to_string(),
|
||||||
url: form.url.trim().to_string(),
|
url: form.url.trim().to_string(),
|
||||||
@@ -176,8 +335,8 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions:
|
|||||||
if form.sending {
|
if form.sending {
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
ui.label(ui::muted("adding…"));
|
ui.label(ui::muted("adding…"));
|
||||||
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
} else if form.url.trim().is_empty() {
|
||||||
ui.label(ui::muted("A name and a link are needed."));
|
ui.label(ui::muted("Paste a share link to add it."));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -211,6 +370,7 @@ mod tests {
|
|||||||
name: "anime".into(),
|
name: "anime".into(),
|
||||||
token: token.into(),
|
token: token.into(),
|
||||||
has_custom_password: false,
|
has_custom_password: false,
|
||||||
|
folder: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::app::{Action, Page};
|
|||||||
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||||
use crate::playback::PlaybackTarget;
|
use crate::playback::PlaybackTarget;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
use crate::ui::{self, Art, Card};
|
use crate::ui::{self, Art, Card};
|
||||||
|
|
||||||
pub struct OfflineView<'a> {
|
pub struct OfflineView<'a> {
|
||||||
@@ -20,6 +21,7 @@ pub fn show(
|
|||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
library: &Library,
|
library: &Library,
|
||||||
key: &str,
|
key: &str,
|
||||||
|
franchise: Option<&[String]>,
|
||||||
offline: OfflineView<'_>,
|
offline: OfflineView<'_>,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
@@ -41,6 +43,15 @@ pub fn show(
|
|||||||
header(ui, art, title, &offline, actions);
|
header(ui, art, title, &offline, actions);
|
||||||
ui.add_space(theme::space::XL);
|
ui.add_space(theme::space::XL);
|
||||||
|
|
||||||
|
if let Some(keys) = franchise {
|
||||||
|
let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
|
||||||
|
if members.len() > 1 {
|
||||||
|
ui::section(ui, "In this franchise");
|
||||||
|
franchise_row(ui, art, title, &members, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One season at a time, picked from a row of pills, rather than
|
// One season at a time, picked from a row of pills, rather than
|
||||||
// every season stacked as a collapsing header: a four-season show
|
// every season stacked as a collapsing header: a four-season show
|
||||||
// was a page of headers to scroll past to reach the one being
|
// was a page of headers to scroll past to reach the one being
|
||||||
@@ -86,6 +97,7 @@ pub fn show(
|
|||||||
)));
|
)));
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
season_download(ui, title, season, &offline, actions);
|
season_download(ui, title, season, &offline, actions);
|
||||||
|
season_watched(ui, title, season, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(theme::space::L);
|
ui.add_space(theme::space::L);
|
||||||
@@ -100,6 +112,60 @@ pub fn show(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every title of the franchise, in release order, as covers — this one
|
||||||
|
/// marked rather than left out, so the row says where it sits in the story.
|
||||||
|
fn franchise_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
current: &Title,
|
||||||
|
members: &[&Title],
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt(("franchise", ¤t.key))
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
|
for member in members {
|
||||||
|
let here = member.key == current.key;
|
||||||
|
let picture = art.poster(member);
|
||||||
|
let found = art
|
||||||
|
.metadata
|
||||||
|
.get(&member.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref());
|
||||||
|
let what = found
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| details.format_label())
|
||||||
|
.unwrap_or(match member.kind {
|
||||||
|
TitleKind::Series => "Series",
|
||||||
|
TitleKind::Film => "Film",
|
||||||
|
});
|
||||||
|
let year = found
|
||||||
|
.and_then(|found| found.year)
|
||||||
|
.or(member.year)
|
||||||
|
.map(|year| format!(" · {year}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let response = ui::card(
|
||||||
|
ui,
|
||||||
|
Card {
|
||||||
|
art: picture,
|
||||||
|
name: art.name(member),
|
||||||
|
subtitle: format!("{what}{year}"),
|
||||||
|
progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
|
badge: here.then(|| "Here".to_owned()),
|
||||||
|
width: 128.0,
|
||||||
|
aspect: theme::POSTER_ASPECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !here && response.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(member.key.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// A title's poster, at the poster's own shape, with how far into it the
|
/// A title's poster, at the poster's own shape, with how far into it the
|
||||||
/// viewer is along its foot.
|
/// viewer is along its foot.
|
||||||
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
|
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
|
||||||
@@ -143,6 +209,29 @@ fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Download, or delete, a whole season.
|
/// Download, or delete, a whole season.
|
||||||
|
/// Mark a whole season seen, or unseen once it all is — for someone who
|
||||||
|
/// watched it elsewhere and joins the show here.
|
||||||
|
fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &mut Vec<Action>) {
|
||||||
|
let all = season.episodes.iter().all(|episode| episode.is_watched());
|
||||||
|
let label = if all {
|
||||||
|
"Mark season unwatched"
|
||||||
|
} else {
|
||||||
|
"Mark season watched"
|
||||||
|
};
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
actions.push(Action::MarkEpisodes {
|
||||||
|
key: title.key.clone(),
|
||||||
|
links: season
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||||
|
.collect(),
|
||||||
|
watched: !all,
|
||||||
|
what: format!("{} {}", title.name, season.label()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn season_download(
|
fn season_download(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
title: &Title,
|
title: &Title,
|
||||||
@@ -158,9 +247,10 @@ fn season_download(
|
|||||||
.on_hover_text("Delete the offline copies; keep the online source")
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
for key in season_keys {
|
actions.push(Action::DeleteOffline(
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
season_keys,
|
||||||
}
|
format!("{} {}", title.name, season.label()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
} else if ui
|
} else if ui
|
||||||
.button("Download season")
|
.button("Download season")
|
||||||
@@ -190,16 +280,19 @@ fn header(
|
|||||||
let record = art.metadata.get(&title.key);
|
let record = art.metadata.get(&title.key);
|
||||||
let found = record.and_then(|record| record.metadata.clone());
|
let found = record.and_then(|record| record.metadata.clone());
|
||||||
let by_hand = record.is_some_and(|record| record.manual);
|
let by_hand = record.is_some_and(|record| record.manual);
|
||||||
// Asked for once and used twice: the same picture is the poster beside the
|
// The cover beside the text, and the widest, largest picture there is
|
||||||
// text and the banner behind all of it.
|
// behind all of it — two textures, because the band is drawn across the
|
||||||
let picture = art.of(title);
|
// whole window and a tile-sized one stretched that far is soft.
|
||||||
|
let picture = art.poster(title);
|
||||||
|
let wide = art.wide(title);
|
||||||
|
|
||||||
// Reserved before the header is laid out, because the band is as tall as
|
// Reserved before the header is laid out, because the band is as tall as
|
||||||
// whatever the header comes to — a fixed height would clip a long overview
|
// whatever the header comes to — a fixed height would clip a long overview
|
||||||
// on one title and leave a gap under a short one on the next.
|
// on one title and leave a gap under a short one on the next.
|
||||||
let backdrop = picture
|
let backdrop = wide
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
||||||
|
let shown = art.name(title).to_owned();
|
||||||
|
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
@@ -214,11 +307,12 @@ fn header(
|
|||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: picture,
|
art: picture,
|
||||||
name: &title.name,
|
name: &shown,
|
||||||
subtitle: String::new(),
|
subtitle: String::new(),
|
||||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
badge: None,
|
badge: None,
|
||||||
width: theme::CARD_WIDTH,
|
width: theme::CARD_WIDTH,
|
||||||
|
aspect: theme::CARD_ASPECT,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -229,16 +323,24 @@ fn header(
|
|||||||
// Text in a column a person can read, with the picture to the
|
// Text in a column a person can read, with the picture to the
|
||||||
// right of it rather than under it.
|
// right of it rather than under it.
|
||||||
ui.set_max_width(ui.available_width().min(820.0));
|
ui.set_max_width(ui.available_width().min(820.0));
|
||||||
ui.label(theme::Role::Display.rich(&title.name).strong());
|
ui.label(theme::Role::Display.rich(&shown).strong());
|
||||||
// The provider's name for it, when it is not the one the files use.
|
// Whichever of the share's name and the provider's is not the one
|
||||||
// Worth showing rather than replacing the filename's: a viewer
|
// shown. Worth showing rather than only replacing: a viewer should
|
||||||
// should be able to tell what the match actually matched.
|
// be able to tell what the match actually matched, and find the
|
||||||
if let Some(found) = &found
|
// folder it came from.
|
||||||
&& found.name != title.name
|
let other = found
|
||||||
{
|
.as_ref()
|
||||||
|
.map(|found| {
|
||||||
|
if shown == found.name {
|
||||||
|
title.name.clone()
|
||||||
|
} else {
|
||||||
|
found.name.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(|other| *other != shown);
|
||||||
|
if let Some(other) = other {
|
||||||
ui.label(ui::muted(format!(
|
ui.label(ui::muted(format!(
|
||||||
"also known as {}{}",
|
"also known as {other}{}",
|
||||||
found.name,
|
|
||||||
if by_hand { " (chosen by hand)" } else { "" }
|
if by_hand { " (chosen by hand)" } else { "" }
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -289,9 +391,7 @@ fn header(
|
|||||||
.on_hover_text("Delete the offline copies; keep the online source")
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
for key in title_keys {
|
actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if ui
|
} else if ui
|
||||||
.button("Download show")
|
.button("Download show")
|
||||||
@@ -449,12 +549,22 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
|
|
||||||
/// What the provider had to say. Only ever drawn under a match.
|
/// What the provider had to say. Only ever drawn under a match.
|
||||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||||
if !found.genres.is_empty() {
|
let tags = found
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.map(|details| details.tags.as_slice())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !found.genres.is_empty() || !tags.is_empty() {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 6.0;
|
ui.spacing_mut().item_spacing.x = 6.0;
|
||||||
for genre in &found.genres {
|
for genre in &found.genres {
|
||||||
ui::widgets::chip(ui, genre);
|
ui::widgets::chip(ui, genre);
|
||||||
}
|
}
|
||||||
|
// After the genres and quieter than them: a tag is the
|
||||||
|
// provider's community describing it, not what it is filed as.
|
||||||
|
for tag in tags.iter().take(5) {
|
||||||
|
ui.label(ui::muted(tag));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.add_space(theme::space::M);
|
ui.add_space(theme::space::M);
|
||||||
}
|
}
|
||||||
@@ -530,6 +640,30 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.push(format!("{watched} watched"));
|
parts.push(format!("{watched} watched"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(details) = found.and_then(|found| found.details.as_ref()) {
|
||||||
|
if let Some(season) = details.season_label() {
|
||||||
|
parts.push(season);
|
||||||
|
}
|
||||||
|
parts.extend(details.studios.iter().take(2).cloned());
|
||||||
|
if let Some(director) = details.directors.first() {
|
||||||
|
parts.push(format!("dir. {director}"));
|
||||||
|
}
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as i64);
|
||||||
|
if let Some((episode, at)) = details.next_airing(now) {
|
||||||
|
let when = chrono::DateTime::from_timestamp(at, 0)
|
||||||
|
.map(|at| {
|
||||||
|
at.with_timezone(&chrono::Local)
|
||||||
|
.format("%a %-d %b, %H:%M")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts.push(format!("episode {episode} airs {when}"));
|
||||||
|
} else if details.is_airing() {
|
||||||
|
parts.push("airing".to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(found) = found {
|
if let Some(found) = found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
@@ -650,14 +784,33 @@ fn episode_row(
|
|||||||
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
if ui::widgets::watched_mark(ui, watched)
|
let mark = ui::widgets::watched_mark(ui, watched).on_hover_text(if watched {
|
||||||
.on_hover_text(if watched {
|
"Mark unwatched. Right-click for more."
|
||||||
"Mark unwatched"
|
} else {
|
||||||
} else {
|
"Mark watched. Right-click for more."
|
||||||
"Mark watched"
|
});
|
||||||
})
|
// Joining a show partway is the usual reason to mark
|
||||||
.clicked()
|
// anything, and one tick per earlier episode is a chore.
|
||||||
{
|
mark.context_menu(|ui| {
|
||||||
|
if ui.button("Mark watched up to here").clicked() {
|
||||||
|
let links = title
|
||||||
|
.episodes()
|
||||||
|
.take(
|
||||||
|
title
|
||||||
|
.position_of(&episode.node.share_id, &episode.node.link_id)
|
||||||
|
.map_or(0, |at| at + 1),
|
||||||
|
)
|
||||||
|
.map(|e| (e.node.share_id.clone(), e.node.link_id.clone()))
|
||||||
|
.collect();
|
||||||
|
actions.push(Action::MarkEpisodes {
|
||||||
|
key: title.key.clone(),
|
||||||
|
links,
|
||||||
|
watched: true,
|
||||||
|
what: format!("{} up to {}", title.name, episode.label()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if mark.clicked() {
|
||||||
actions.push(Action::SetWatched {
|
actions.push(Action::SetWatched {
|
||||||
share_id: episode.node.share_id.clone(),
|
share_id: episode.node.share_id.clone(),
|
||||||
link_id: episode.node.link_id.clone(),
|
link_id: episode.node.link_id.clone(),
|
||||||
@@ -726,6 +879,7 @@ pub(crate) fn still(
|
|||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
let response = response.named(format!("Play {}", episode.label()));
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -831,7 +985,7 @@ fn download(
|
|||||||
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
actions.push(Action::DeleteOffline(vec![key], episode.label()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
|||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::Action;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// How long a message stays up.
|
/// How long a message stays up.
|
||||||
const SECONDS: f64 = 5.0;
|
const SECONDS: f64 = 5.0;
|
||||||
@@ -52,6 +53,17 @@ impl Toasts {
|
|||||||
self.push_inner(now, text.into(), false, Some((label, action)));
|
self.push_inner(now, text.into(), false, Some((label, action)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An error with a button on it — "Retry".
|
||||||
|
pub fn push_error_with(
|
||||||
|
&mut self,
|
||||||
|
now: f64,
|
||||||
|
text: impl Into<String>,
|
||||||
|
label: &'static str,
|
||||||
|
action: Action,
|
||||||
|
) {
|
||||||
|
self.push_inner(now, text.into(), true, Some((label, action)));
|
||||||
|
}
|
||||||
|
|
||||||
fn push_inner(
|
fn push_inner(
|
||||||
&mut self,
|
&mut self,
|
||||||
now: f64,
|
now: f64,
|
||||||
@@ -203,7 +215,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
|||||||
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||||
Stroke::new(1.5, ink),
|
Stroke::new(1.5, ink),
|
||||||
);
|
);
|
||||||
if response.on_hover_text("Dismiss").clicked() {
|
if response.hint("Dismiss").clicked() {
|
||||||
pressed = Pressed::Close;
|
pressed = Pressed::Close;
|
||||||
}
|
}
|
||||||
if let Some((label, _)) = &toast.action {
|
if let Some((label, _)) = &toast.action {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use crate::app::{Action, Adjacent, Page};
|
|||||||
use crate::playback::{Command, Playback};
|
use crate::playback::{Command, Playback};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use crate::ui::widgets::Named;
|
||||||
|
|
||||||
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
|
||||||
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
|
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
|
||||||
@@ -163,7 +164,7 @@ pub fn mini(
|
|||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if response.on_hover_text("Show the picture again").clicked() {
|
if response.hint("Show the picture again").clicked() {
|
||||||
actions.push(Action::Goto(Page::Player));
|
actions.push(Action::Goto(Page::Player));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,7 +314,7 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response
|
if response
|
||||||
.on_hover_text(if playback.paused {
|
.hint(if playback.paused {
|
||||||
"Play (Space)"
|
"Play (Space)"
|
||||||
} else {
|
} else {
|
||||||
"Pause (Space)"
|
"Pause (Space)"
|
||||||
@@ -354,7 +355,7 @@ fn step_button(
|
|||||||
enabled,
|
enabled,
|
||||||
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
||||||
);
|
);
|
||||||
if response.on_hover_text(hover).clicked() {
|
if response.hint(hover).clicked() {
|
||||||
push(actions);
|
push(actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +437,7 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
|||||||
};
|
};
|
||||||
if ui
|
if ui
|
||||||
.button(theme::Role::Subhead.rich(icon))
|
.button(theme::Role::Subhead.rich(icon))
|
||||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
.hint(format!("Mute (M) — {:.0}%", playback.volume))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleMute);
|
actions.push(Action::ToggleMute);
|
||||||
@@ -509,7 +510,8 @@ fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>)
|
|||||||
format!("Speed · {}", ui::format_speed(playback.speed))
|
format!("Speed · {}", ui::format_speed(playback.speed))
|
||||||
};
|
};
|
||||||
ui.menu_button(label, |ui| {
|
ui.menu_button(label, |ui| {
|
||||||
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
|
// The same steps `[` and `]` walk.
|
||||||
|
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
(playback.speed - speed).abs() < 1e-6,
|
(playback.speed - speed).abs() < 1e-6,
|
||||||
@@ -566,6 +568,13 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
let width = ui.available_width();
|
let width = ui.available_width();
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||||
|
response.widget_info(|| {
|
||||||
|
egui::WidgetInfo::slider(
|
||||||
|
playback.duration.is_some(),
|
||||||
|
playback.position,
|
||||||
|
format!("Position, {}", ui::format_time(playback.position)),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
let active = response.hovered() || response.dragged();
|
let active = response.hovered() || response.dragged();
|
||||||
// Grows rather than jumps when the pointer arrives.
|
// Grows rather than jumps when the pointer arrives.
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
|||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
|
||||||
|
/// A name for a screen reader, on a control whose face does not say it.
|
||||||
|
///
|
||||||
|
/// A button drawn as an icon glyph is announced as the icon font's private
|
||||||
|
/// code point, and one painted by hand as nothing at all.
|
||||||
|
pub trait Named {
|
||||||
|
/// The control's name to a screen reader.
|
||||||
|
fn named(self, name: impl Into<String>) -> Self;
|
||||||
|
/// Hover text that is also the control's name.
|
||||||
|
fn hint(self, text: impl Into<String>) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Named for egui::Response {
|
||||||
|
fn named(self, name: impl Into<String>) -> Self {
|
||||||
|
let name = name.into();
|
||||||
|
let enabled = self.enabled();
|
||||||
|
self.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hint(self, text: impl Into<String>) -> Self {
|
||||||
|
let text = text.into();
|
||||||
|
self.named(text.clone()).on_hover_text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An on/off switch with its label beside it, for settings.
|
/// An on/off switch with its label beside it, for settings.
|
||||||
///
|
///
|
||||||
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
||||||
@@ -98,6 +123,9 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
|||||||
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
||||||
const SIZE: f32 = 20.0;
|
const SIZE: f32 = 20.0;
|
||||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
|
response.widget_info(|| {
|
||||||
|
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, watched, "Watched")
|
||||||
|
});
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let ctx = ui.ctx();
|
let ctx = ui.ctx();
|
||||||
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||||
@@ -278,6 +306,12 @@ pub enum DownloadGlyph {
|
|||||||
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
||||||
const SIZE: f32 = 30.0;
|
const SIZE: f32 = 30.0;
|
||||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
|
let response = response.named(match glyph {
|
||||||
|
DownloadGlyph::Download => "Download".to_owned(),
|
||||||
|
DownloadGlyph::Progress(done) => format!("Downloading, {:.0}%", done * 100.0),
|
||||||
|
DownloadGlyph::Paused(done) => format!("Download paused, {:.0}%", done * 100.0),
|
||||||
|
DownloadGlyph::Done => "Downloaded".to_owned(),
|
||||||
|
});
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Proton's CAPTCHA, in a window of its own.
|
||||||
|
//!
|
||||||
|
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
|
||||||
|
//! network, a VPN exit. The page reports success by posting a message to its
|
||||||
|
//! host rather than by redirecting, so the system browser has nowhere to send
|
||||||
|
//! it back to: it needs a webview this app controls, as on Android and in
|
||||||
|
//! `proton-drive-linux`.
|
||||||
|
//!
|
||||||
|
//! The webview runs in a child process — this same executable, started with
|
||||||
|
//! [`ARG`] — because its windowing library and eframe's cannot both own the
|
||||||
|
//! event loop of one process. The child prints the solved token on stdout and
|
||||||
|
//! exits; closing its window exits with nothing printed, which is a cancel.
|
||||||
|
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
|
||||||
|
/// The argument that starts this executable as the verification window,
|
||||||
|
/// followed by the page's URL.
|
||||||
|
pub const ARG: &str = "--human-verification";
|
||||||
|
|
||||||
|
/// Forwards every message the page posts to this process. The page targets
|
||||||
|
/// `window.parent`, which for a top-level page is the page itself, so a
|
||||||
|
/// listener on its own window hears it.
|
||||||
|
const BRIDGE: &str = r#"
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Run the verification window if this process was started for it, returning
|
||||||
|
/// the process's exit code; `None` when it was started as the app.
|
||||||
|
pub fn run_if_requested() -> Option<i32> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
if args.next().as_deref() != Some(ARG) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
|
||||||
|
eprintln!("{ARG} needs a verification page on proton.me");
|
||||||
|
return Some(2);
|
||||||
|
};
|
||||||
|
Some(match window(&url) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("the verification window failed: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present `url` in the verification window and wait for the token it yields.
|
||||||
|
///
|
||||||
|
/// `None` when the viewer closed the window, or it could not be shown.
|
||||||
|
/// Blocking: call it off the UI thread and outside the async runtime.
|
||||||
|
pub fn solve(url: &str) -> Option<String> {
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
let mut child = std::process::Command::new(exe)
|
||||||
|
.arg(ARG)
|
||||||
|
.arg(url)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(mut stdout) = child.stdout.take() {
|
||||||
|
let _ = stdout.read_to_string(&mut out);
|
||||||
|
}
|
||||||
|
let status = child.wait().ok()?;
|
||||||
|
if !status.success() {
|
||||||
|
tracing::warn!("the verification window exited with {status}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
out.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.rfind(|line| !line.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window(url: &str) -> anyhow::Result<()> {
|
||||||
|
use tao::event::{Event, WindowEvent};
|
||||||
|
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||||
|
use tao::window::WindowBuilder;
|
||||||
|
use wry::{NewWindowResponse, WebViewBuilder};
|
||||||
|
|
||||||
|
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
|
||||||
|
let window = WindowBuilder::new()
|
||||||
|
.with_title("Confirm you are human — Proton Stream")
|
||||||
|
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
|
||||||
|
.build(&event_loop)?;
|
||||||
|
|
||||||
|
let proxy = event_loop.create_proxy();
|
||||||
|
let builder = WebViewBuilder::new()
|
||||||
|
.with_url(url)
|
||||||
|
.with_initialization_script(BRIDGE)
|
||||||
|
.with_ipc_handler(move |request| {
|
||||||
|
// Every message the page posts lands here, most of them its own
|
||||||
|
// chatter; only a completion carries a token.
|
||||||
|
if let Some(token) = pstr_core::account::verification_token(request.body()) {
|
||||||
|
let _ = proxy.send_event(token);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// The bridge above is reachable by whatever the page shows, so the
|
||||||
|
// page stays on Proton.
|
||||||
|
.with_navigation_handler(|url| is_allowed_navigation(&url))
|
||||||
|
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
let _webview = {
|
||||||
|
use tao::platform::unix::WindowExtUnix as _;
|
||||||
|
use wry::WebViewBuilderExtUnix as _;
|
||||||
|
let vbox = window
|
||||||
|
.default_vbox()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
|
||||||
|
builder.build_gtk(vbox)?
|
||||||
|
};
|
||||||
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
|
let _webview = builder.build(&window)?;
|
||||||
|
|
||||||
|
let mut answered = false;
|
||||||
|
event_loop.run(move |event, _, control_flow| {
|
||||||
|
*control_flow = ControlFlow::Wait;
|
||||||
|
match event {
|
||||||
|
Event::UserEvent(token) if !answered => {
|
||||||
|
answered = true;
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
let _ = writeln!(stdout, "{token}");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
*control_flow = ControlFlow::Exit;
|
||||||
|
}
|
||||||
|
Event::WindowEvent {
|
||||||
|
event: WindowEvent::CloseRequested,
|
||||||
|
..
|
||||||
|
} => *control_flow = ControlFlow::Exit,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` is a page of Proton's own, over HTTPS.
|
||||||
|
fn is_proton_page(url: &str) -> bool {
|
||||||
|
let Some(rest) = url.strip_prefix("https://") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||||
|
// A user-info part would let `proton.me@evil.example` pass.
|
||||||
|
if authority.contains('@') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let host = authority.split(':').next().unwrap_or_default();
|
||||||
|
host == "proton.me" || host.ends_with(".proton.me")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the webview may go to `url`: Proton's pages, and the documents a
|
||||||
|
/// page makes for itself, which have no origin of their own to leave to.
|
||||||
|
fn is_allowed_navigation(url: &str) -> bool {
|
||||||
|
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_proton_pages_over_https_are_proton_pages() {
|
||||||
|
assert!(is_proton_page(
|
||||||
|
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
|
||||||
|
));
|
||||||
|
assert!(is_proton_page("https://proton.me"));
|
||||||
|
assert!(!is_proton_page("http://verify.proton.me/"));
|
||||||
|
assert!(!is_proton_page("https://proton.me.evil.example/"));
|
||||||
|
assert!(!is_proton_page("https://evilproton.me/"));
|
||||||
|
assert!(!is_proton_page("https://proton.me@evil.example/"));
|
||||||
|
assert!(!is_proton_page(
|
||||||
|
"https://evil.example/?next=https://proton.me"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_window_stays_on_proton() {
|
||||||
|
assert!(is_allowed_navigation(
|
||||||
|
"https://verify-api.proton.me/core/v4/captcha"
|
||||||
|
));
|
||||||
|
assert!(is_allowed_navigation("about:blank"));
|
||||||
|
assert!(!is_allowed_navigation("https://example.com/"));
|
||||||
|
assert!(!is_allowed_navigation("file:///etc/passwd"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,6 @@ tokio.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
@@ -8,9 +8,12 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use pstr_core::catalog::{Catalog, build_rows};
|
use pstr_core::account::{PlaceKind, SignIn};
|
||||||
|
use pstr_core::catalog::{Catalog, build_rows, build_sidecars};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::{ShareStore, SharedLibrary};
|
use pstr_core::shares::AccountFolder;
|
||||||
|
use pstr_core::sync::WatchSync;
|
||||||
|
use pstr_core::{Account, AccountStore, KeyringSecretStore, ShareStore, SharedLibrary};
|
||||||
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
|
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
|
||||||
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
|
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
|
||||||
|
|
||||||
@@ -69,8 +72,38 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
custom_password: bool,
|
custom_password: bool,
|
||||||
},
|
},
|
||||||
|
/// Add a folder of the signed-in account as a share.
|
||||||
|
///
|
||||||
|
/// The folder is named by the `VOLUME:LINK` id `browse` prints.
|
||||||
|
AddFolder {
|
||||||
|
/// What to call it in the UI.
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
/// The folder, as `VOLUME:LINK`.
|
||||||
|
folder: String,
|
||||||
|
},
|
||||||
/// List configured shares.
|
/// List configured shares.
|
||||||
Shares,
|
Shares,
|
||||||
|
/// Sign in to a Proton account, for watch history across devices and for
|
||||||
|
/// folders of your own Drive.
|
||||||
|
///
|
||||||
|
/// The password, a second-factor code and a mailbox password are prompted
|
||||||
|
/// for as the account needs them.
|
||||||
|
Login {
|
||||||
|
/// The account's address.
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
/// Sign out and forget the stored session.
|
||||||
|
Logout,
|
||||||
|
/// Show which account is signed in.
|
||||||
|
Account,
|
||||||
|
/// List the account's Drive: its top-level places, or one folder.
|
||||||
|
Browse {
|
||||||
|
/// The folder to list, as `VOLUME:LINK`; the places when omitted.
|
||||||
|
folder: Option<String>,
|
||||||
|
},
|
||||||
|
/// Sync watch history with the signed-in account's Drive.
|
||||||
|
Sync,
|
||||||
/// Forget a share and its stored credentials.
|
/// Forget a share and its stored credentials.
|
||||||
Remove {
|
Remove {
|
||||||
/// The share id, as printed by `shares`.
|
/// The share id, as printed by `shares`.
|
||||||
@@ -210,6 +243,7 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||||
let store = ShareStore::new(dirs.clone());
|
let store = ShareStore::new(dirs.clone());
|
||||||
|
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Add {
|
Command::Add {
|
||||||
@@ -222,12 +256,105 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let share = store
|
let (share, _) = store
|
||||||
.add(&name, &url, password.as_deref())
|
.add_verified(&name, &url, password.as_deref())
|
||||||
|
.await
|
||||||
.context("add share")?;
|
.context("add share")?;
|
||||||
println!("added {} ({})", share.name, share.id);
|
println!("added {} ({})", share.name, share.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::AddFolder { name, folder } => {
|
||||||
|
let uid = parse_uid(&folder)?;
|
||||||
|
let share = store
|
||||||
|
.add_folder(
|
||||||
|
&name,
|
||||||
|
AccountFolder {
|
||||||
|
volume_id: uid.volume_id.as_str().to_owned(),
|
||||||
|
link_id: uid.link_id.as_str().to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.context("add folder")?;
|
||||||
|
println!("added {} ({})", share.name, share.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Login { username } => login(&accounts, &username).await?,
|
||||||
|
|
||||||
|
Command::Logout => {
|
||||||
|
let account = accounts.resume().ok().flatten();
|
||||||
|
accounts
|
||||||
|
.sign_out(account.as_ref())
|
||||||
|
.await
|
||||||
|
.context("sign out")?;
|
||||||
|
println!("signed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Account => match accounts.username().context("read the account")? {
|
||||||
|
Some(username) => println!("signed in as {username}"),
|
||||||
|
None => println!("not signed in — sign in with `pstr login <address>`"),
|
||||||
|
},
|
||||||
|
|
||||||
|
Command::Browse { folder } => {
|
||||||
|
let account = signed_in(&accounts)?;
|
||||||
|
match folder {
|
||||||
|
None => {
|
||||||
|
for place in account.places().await.context("list places")? {
|
||||||
|
let kind = match place.kind {
|
||||||
|
PlaceKind::MyFiles => "files",
|
||||||
|
PlaceKind::Device => "device",
|
||||||
|
PlaceKind::SharedWithMe => "shared",
|
||||||
|
};
|
||||||
|
println!("{}\t{kind}\t{}", format_uid(&place.uid), place.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(folder) => {
|
||||||
|
let entries = account
|
||||||
|
.folder_children(&parse_uid(&folder)?)
|
||||||
|
.await
|
||||||
|
.context("list folder")?;
|
||||||
|
for entry in entries {
|
||||||
|
let kind = if entry.is_folder { "dir" } else { "file" };
|
||||||
|
println!("{}\t{kind}\t{}", format_uid(&entry.uid), entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::Sync => {
|
||||||
|
let account = signed_in(&accounts)?;
|
||||||
|
let sync = WatchSync::open(&dirs).context("open sync state")?;
|
||||||
|
let catalog =
|
||||||
|
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
|
||||||
|
let report = sync
|
||||||
|
.sync(&account, &catalog, &store)
|
||||||
|
.await
|
||||||
|
.context("sync")?;
|
||||||
|
let profile = &report.profile;
|
||||||
|
for id in &profile.shares_removed {
|
||||||
|
catalog
|
||||||
|
.lock()
|
||||||
|
.remove_share(id)
|
||||||
|
.context("drop a removed share")?;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"read {} other device(s), took {} newer position(s), \
|
||||||
|
{} share(s) added, {} removed, settings {}, {}",
|
||||||
|
report.devices,
|
||||||
|
report.applied,
|
||||||
|
profile.shares_added.len() + profile.shares_changed.len(),
|
||||||
|
profile.shares_removed.len(),
|
||||||
|
if profile.settings {
|
||||||
|
"updated"
|
||||||
|
} else {
|
||||||
|
"unchanged"
|
||||||
|
},
|
||||||
|
if report.uploaded {
|
||||||
|
"uploaded this device's history"
|
||||||
|
} else {
|
||||||
|
"this device's history was already up to date"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Command::Shares => {
|
Command::Shares => {
|
||||||
let shares = store.list().context("list shares")?;
|
let shares = store.list().context("list shares")?;
|
||||||
if shares.is_empty() {
|
if shares.is_empty() {
|
||||||
@@ -239,17 +366,41 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
println!("{}\t{}{}", share.id, share.name, custom);
|
let kind = if share.folder.is_some() {
|
||||||
|
" [account folder]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
println!("{}\t{}{}{}", share.id, share.name, custom, kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Remove { id } => {
|
Command::Remove { id } => {
|
||||||
store.remove(&id).context("remove share")?;
|
store.remove(&id).context("remove share")?;
|
||||||
|
// Its catalog rows go too, or the library keeps listing its files —
|
||||||
|
// beside the new share's, when the same folder was added back
|
||||||
|
// another way.
|
||||||
|
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||||
|
let offline = catalog
|
||||||
|
.offline_files_for_share(&id)
|
||||||
|
.context("read the share's downloads")?;
|
||||||
|
for (link_id, file) in &offline {
|
||||||
|
match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) {
|
||||||
|
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
|
||||||
|
return Err(error).context("delete a downloaded file");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catalog
|
||||||
|
.remove_share(&id)
|
||||||
|
.context("drop the share's catalog rows")?;
|
||||||
println!("removed {id}");
|
println!("removed {id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::Crawl { share } => {
|
Command::Crawl { share } => {
|
||||||
let (library, failures) = SharedLibrary::open_all(&store)
|
let account = accounts.resume().context("resume the account")?;
|
||||||
|
let (library, failures) = SharedLibrary::open_all(&store, account.as_ref())
|
||||||
.await
|
.await
|
||||||
.context("open shares")?;
|
.context("open shares")?;
|
||||||
for (share, error) in &failures {
|
for (share, error) in &failures {
|
||||||
@@ -274,6 +425,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
|||||||
|
|
||||||
catalog
|
catalog
|
||||||
.replace_share(&share_id, &rows)
|
.replace_share(&share_id, &rows)
|
||||||
|
.and_then(|()| {
|
||||||
|
catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes))
|
||||||
|
})
|
||||||
.with_context(|| format!("store {share_id}"))?;
|
.with_context(|| format!("store {share_id}"))?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
@@ -483,18 +637,19 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.context("build the metadata provider")?;
|
.context("build the metadata provider")?;
|
||||||
|
|
||||||
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||||
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
|
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
|
||||||
let stored = catalog.all_metadata()?;
|
let stored = catalog.all_metadata()?;
|
||||||
|
let enriched = catalog.enrichment_ages()?;
|
||||||
|
|
||||||
let mut pending: Vec<_> = library
|
let mut pending = pstr_meta::service::plan(
|
||||||
.titles
|
library.titles,
|
||||||
.iter()
|
&stored,
|
||||||
.filter(|title| {
|
&enriched,
|
||||||
force
|
settings.provider,
|
||||||
|| !pstr_meta::service::is_usable(stored.get(&title.key), settings.provider)
|
service.has_details(),
|
||||||
})
|
force,
|
||||||
.collect();
|
);
|
||||||
if let Some(limit) = limit {
|
if let Some(limit) = limit {
|
||||||
pending.truncate(limit);
|
pending.truncate(limit);
|
||||||
}
|
}
|
||||||
@@ -504,7 +659,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"matching {} titles against {}…",
|
"looking up {} titles against {}…",
|
||||||
pending.len(),
|
pending.len(),
|
||||||
settings.provider.label()
|
settings.provider.label()
|
||||||
);
|
);
|
||||||
@@ -513,32 +668,71 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
|||||||
// and the GUI already has the parallel path. What this command is
|
// and the GUI already has the parallel path. What this command is
|
||||||
// for is reading the output.
|
// for is reading the output.
|
||||||
let (mut matched, mut failed) = (0usize, 0usize);
|
let (mut matched, mut failed) = (0usize, 0usize);
|
||||||
for title in pending {
|
for work in pending {
|
||||||
match service.record(title).await {
|
use pstr_meta::service::Work;
|
||||||
Ok(record) => {
|
let name = &work.title().name;
|
||||||
match &record.metadata {
|
// Whether this work asks for episodes at all — a refresh of an
|
||||||
Some(found) => {
|
// already-enriched title does not, and saying they were "not
|
||||||
matched += 1;
|
// fetched" there reads as a failure.
|
||||||
println!(
|
let enriches = matches!(work, Work::Match(_) | Work::Refresh { enrich: true, .. });
|
||||||
" {:<40} → {} ({})",
|
let outcome = match service.work(&work).await {
|
||||||
title.name,
|
Ok(outcome) => outcome,
|
||||||
found.name,
|
|
||||||
found
|
|
||||||
.year
|
|
||||||
.map(|year| year.to_string())
|
|
||||||
.unwrap_or_else(|| "-".into())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
None => println!(" {:<40} → no match", title.name),
|
|
||||||
}
|
|
||||||
catalog.set_metadata(&record)?;
|
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
// Not stored: a failure to ask is not an answer, and the
|
// Not stored: a failure to ask is not an answer, and the
|
||||||
// title has to stay askable. See `pstr_meta::service`.
|
// title has to stay askable. See `pstr_meta::service`.
|
||||||
println!(" {:<40} ! {error}", title.name);
|
println!(" {name:<40} ! {error}");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(record) = &outcome.record {
|
||||||
|
match &record.metadata {
|
||||||
|
Some(found) => {
|
||||||
|
matched += 1;
|
||||||
|
println!(
|
||||||
|
" {name:<40} → {} ({})",
|
||||||
|
found.name,
|
||||||
|
found
|
||||||
|
.year
|
||||||
|
.map(|year| year.to_string())
|
||||||
|
.unwrap_or_else(|| "-".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => println!(" {name:<40} → no match"),
|
||||||
|
}
|
||||||
|
catalog.set_metadata(record)?;
|
||||||
|
}
|
||||||
|
match &outcome.enrichment {
|
||||||
|
Some(enrichment) => {
|
||||||
|
println!(
|
||||||
|
" {:<40} {} episodes{}",
|
||||||
|
"",
|
||||||
|
enrichment.episodes.len(),
|
||||||
|
if enrichment.backdrop_url.is_some() {
|
||||||
|
", fanart"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
catalog.set_enrichment(
|
||||||
|
&outcome.title_key,
|
||||||
|
settings.provider,
|
||||||
|
unix_now(),
|
||||||
|
enrichment,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
// Matched, but the episodes could not be asked for; the
|
||||||
|
// next run asks again.
|
||||||
|
None if enriches
|
||||||
|
&& outcome
|
||||||
|
.record
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| r.metadata.is_some()) =>
|
||||||
|
{
|
||||||
|
println!(" {:<40} ! episodes not fetched", "");
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("{matched} matched, {failed} could not be looked up");
|
println!("{matched} matched, {failed} could not be looked up");
|
||||||
@@ -603,7 +797,10 @@ async fn open_target(
|
|||||||
|
|
||||||
println!("file: {} ({})", target.name, target.share_id);
|
println!("file: {} ({})", target.name, target.share_id);
|
||||||
|
|
||||||
let (library, failures) = SharedLibrary::open_all(store)
|
let account = AccountStore::new(std::sync::Arc::new(KeyringSecretStore))
|
||||||
|
.resume()
|
||||||
|
.context("resume the account")?;
|
||||||
|
let (library, failures) = SharedLibrary::open_all(store, account.as_ref())
|
||||||
.await
|
.await
|
||||||
.context("open shares")?;
|
.context("open shares")?;
|
||||||
for (share, error) in &failures {
|
for (share, error) in &failures {
|
||||||
@@ -926,3 +1123,77 @@ async fn play(
|
|||||||
drop(opened.library);
|
drop(opened.library);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk an interactive sign-in through whatever the account asks for.
|
||||||
|
async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
|
||||||
|
let password = read_password("Password: ")?;
|
||||||
|
let mut step = match accounts.sign_in(username, &password, None).await {
|
||||||
|
Ok(step) => step,
|
||||||
|
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||||
|
// Solving a CAPTCHA needs a browser engine that can hand the
|
||||||
|
// token back, which a terminal does not have. The desktop app has
|
||||||
|
// one, and keeps its session in the same keyring entry.
|
||||||
|
anyhow::bail!(
|
||||||
|
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
|
||||||
|
desktop app on this computer, which shows it and shares its session with \
|
||||||
|
pstr, or try again later. ({})",
|
||||||
|
challenge.verification_url()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error).context("sign in"),
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
step = match step {
|
||||||
|
SignIn::Done(account) => {
|
||||||
|
println!("signed in as {}", account.username());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
SignIn::SecondFactor(pending) => {
|
||||||
|
// Security keys need WebAuthn, which this sign-in does not speak.
|
||||||
|
let code = read_password("Code from your authenticator app: ")?;
|
||||||
|
pending
|
||||||
|
.second_factor(&code)
|
||||||
|
.await
|
||||||
|
.context("second factor")?
|
||||||
|
}
|
||||||
|
SignIn::MailboxPassword(pending) => {
|
||||||
|
let mailbox = read_password("Mailbox password: ")?;
|
||||||
|
SignIn::Done(
|
||||||
|
pending
|
||||||
|
.mailbox_password(&mailbox)
|
||||||
|
.await
|
||||||
|
.context("mailbox password")?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_in(accounts: &AccountStore) -> Result<Account> {
|
||||||
|
accounts
|
||||||
|
.resume()
|
||||||
|
.context("resume the account")?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login <address>`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node as `browse` prints it and `add-folder` takes it.
|
||||||
|
fn format_uid(uid: &NodeUid) -> String {
|
||||||
|
format!("{}:{}", uid.volume_id.as_str(), uid.link_id.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uid(text: &str) -> Result<NodeUid> {
|
||||||
|
let (volume, link) = text
|
||||||
|
.split_once(':')
|
||||||
|
.filter(|(volume, link)| !volume.is_empty() && !link.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("expected VOLUME:LINK, as `pstr browse` prints"))?;
|
||||||
|
Ok(NodeUid::new(
|
||||||
|
VolumeId::new(volume.to_owned()),
|
||||||
|
LinkId::new(link.to_owned()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as i64)
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ tracing.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
getrandom.workspace = true
|
||||||
|
|
||||||
# The credential store, per platform. Without the target's feature `keyring`
|
# The credential store, per platform. Without the target's feature `keyring`
|
||||||
# compiles fine and silently resolves to its in-memory `mock` store, which
|
# compiles fine and silently resolves to its in-memory `mock` store, which
|
||||||
|
|||||||
@@ -0,0 +1,705 @@
|
|||||||
|
//! Signing in to a Proton account, and keeping the session.
|
||||||
|
//!
|
||||||
|
//! The rest of the app works without one — public links need no account. A
|
||||||
|
//! signed-in account adds two things: folders of the viewer's own Drive (My
|
||||||
|
//! Files, other devices, what was shared with them) as library shares, and
|
||||||
|
//! watch history kept in their Drive so every device resumes where another
|
||||||
|
//! left off ([`crate::sync`]).
|
||||||
|
//!
|
||||||
|
//! ## What is kept, and where
|
||||||
|
//!
|
||||||
|
//! One JSON blob in the [`SecretStore`] holding the session tokens and, per
|
||||||
|
//! account key, the passphrase that unlocks it. Those passphrases are derived
|
||||||
|
//! at sign-in from the mailbox password and the key salts, and kept *instead*
|
||||||
|
//! of the password: they unlock the key chain just the same, but a store that
|
||||||
|
//! leaks gives away this app's access to the account rather than the password
|
||||||
|
//! — which, on a single-password account, is the login password itself.
|
||||||
|
//! Deriving them later is not an option either way: `core/v4/keys/salts`
|
||||||
|
//! needs a scope only a password sign-in grants, and answers 403 after the
|
||||||
|
//! first token refresh.
|
||||||
|
//!
|
||||||
|
//! Proton refresh tokens are single-use, so every refresh is written back
|
||||||
|
//! (see [`persist_refreshed_tokens`]). A refresh that is not leaves the store
|
||||||
|
//! holding a dead token and the next launch signed out.
|
||||||
|
//!
|
||||||
|
//! ## Signing in is several steps
|
||||||
|
//!
|
||||||
|
//! Password, then maybe a second-factor code, then — on an account with a
|
||||||
|
//! separate mailbox password — that. Each front end asks for them in its own
|
||||||
|
//! way, so [`AccountStore::sign_in`] returns a [`SignIn`] saying what it needs
|
||||||
|
//! next and holding the half-made session until it is given.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use proton_drive_rs::{NodeKind, ProtonDriveClient};
|
||||||
|
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};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::shares::{SecretStore, client_configuration};
|
||||||
|
|
||||||
|
/// The secret-store key the session is kept under. Share secrets are keyed
|
||||||
|
/// `share-…`, so the two cannot collide.
|
||||||
|
const SESSION_KEY: &str = "proton-account";
|
||||||
|
|
||||||
|
/// Why an account whose only second factor is a security key cannot sign in.
|
||||||
|
const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security key, which this \
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Everything needed to resume the session unattended.
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct StoredSession {
|
||||||
|
session_id: String,
|
||||||
|
username: String,
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
/// `1` single password, `2` separate mailbox password — Proton's values.
|
||||||
|
password_mode: u8,
|
||||||
|
/// Key id → the passphrase that unlocks it. ASCII: they are bcrypt output.
|
||||||
|
key_passphrases: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredSession {
|
||||||
|
fn capture(
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
tokens: proton_sdk::http::Tokens,
|
||||||
|
key_passphrases: HashMap<String, String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
|
username: session.username().to_owned(),
|
||||||
|
user_id: session.user_id().as_str().to_owned(),
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
scopes: session.scopes().to_vec(),
|
||||||
|
password_mode: match session.password_mode() {
|
||||||
|
PasswordMode::Single => 1,
|
||||||
|
PasswordMode::Dual => 2,
|
||||||
|
},
|
||||||
|
key_passphrases,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&self) -> KeyPassphrases {
|
||||||
|
self.key_passphrases
|
||||||
|
.iter()
|
||||||
|
.map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume_parameters(&self) -> ResumeParameters {
|
||||||
|
ResumeParameters {
|
||||||
|
session_id: self.session_id.clone().into(),
|
||||||
|
username: self.username.clone(),
|
||||||
|
user_id: self.user_id.clone().into(),
|
||||||
|
access_token: self.access_token.clone(),
|
||||||
|
refresh_token: self.refresh_token.clone(),
|
||||||
|
scopes: self.scopes.clone(),
|
||||||
|
is_waiting_for_second_factor_code: false,
|
||||||
|
password_mode: match self.password_mode {
|
||||||
|
1 => PasswordMode::Single,
|
||||||
|
_ => PasswordMode::Dual,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed-in account, ready for Drive work.
|
||||||
|
///
|
||||||
|
/// Cheap to clone; clones share one session and one set of caches.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Account {
|
||||||
|
session: ProtonApiSession,
|
||||||
|
drive: ProtonDriveClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// The address the viewer signed in with.
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
self.session.username()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Drive client the account's folders and watch history go through.
|
||||||
|
pub fn drive(&self) -> &ProtonDriveClient {
|
||||||
|
&self.drive
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a library folder can come from: My Files, each device, and each
|
||||||
|
/// folder someone shared with the viewer.
|
||||||
|
///
|
||||||
|
/// The device this app keeps watch history in is left out — it holds
|
||||||
|
/// nothing to watch. A device or share whose name will not decrypt is
|
||||||
|
/// still listed, under a placeholder, rather than hidden: it is the
|
||||||
|
/// viewer's, and they can still open it.
|
||||||
|
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
|
||||||
|
let mut places = Vec::new();
|
||||||
|
let my_files = self.drive.get_my_files_folder().await?;
|
||||||
|
places.push(DrivePlace {
|
||||||
|
kind: PlaceKind::MyFiles,
|
||||||
|
name: "My files".to_owned(),
|
||||||
|
uid: my_files.uid,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut devices = self.drive.enumerate_devices().await?;
|
||||||
|
devices.sort_by_key(|device| device.creation_time);
|
||||||
|
for device in devices {
|
||||||
|
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
|
||||||
|
if name == crate::sync::DEVICE_NAME {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
places.push(DrivePlace {
|
||||||
|
kind: PlaceKind::Device,
|
||||||
|
name,
|
||||||
|
uid: device.root_folder_uid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared: Vec<NodeUid> = self
|
||||||
|
.drive
|
||||||
|
.enumerate_shared_with_me()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.uid)
|
||||||
|
.collect();
|
||||||
|
if !shared.is_empty() {
|
||||||
|
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
|
||||||
|
nodes.retain(|node| node.is_folder() && !node.trashed);
|
||||||
|
nodes.sort_by_key(|node| node.name.to_lowercase());
|
||||||
|
places.extend(nodes.into_iter().map(|node| DrivePlace {
|
||||||
|
kind: PlaceKind::SharedWithMe,
|
||||||
|
name: node.name,
|
||||||
|
uid: node.uid,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(places)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one folder of the account holds: folders first, then files, each
|
||||||
|
/// by name. Trashed nodes are left out.
|
||||||
|
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
|
||||||
|
let uids = self
|
||||||
|
.drive
|
||||||
|
.enumerate_folder_children_node_uids(folder)
|
||||||
|
.await?;
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let mut entries: Vec<DriveEntry> = self
|
||||||
|
.drive
|
||||||
|
.enumerate_nodes(&uids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|node| !node.trashed)
|
||||||
|
.map(|node| {
|
||||||
|
let (size, media_type) = match &node.kind {
|
||||||
|
NodeKind::File {
|
||||||
|
media_type,
|
||||||
|
claimed_size,
|
||||||
|
..
|
||||||
|
} => (*claimed_size, Some(media_type.clone())),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
DriveEntry {
|
||||||
|
is_folder: node.is_folder(),
|
||||||
|
uid: node.uid,
|
||||||
|
name: node.name,
|
||||||
|
size,
|
||||||
|
media_type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_folder
|
||||||
|
.cmp(&a.is_folder)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the session on Proton's side. Best-effort: a session that is
|
||||||
|
/// already dead has nothing to end.
|
||||||
|
async fn end(&self) {
|
||||||
|
if let Err(error) = self.session.end().await {
|
||||||
|
tracing::debug!(%error, "ending the Proton session failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which kind of place a [`DrivePlace`] is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlaceKind {
|
||||||
|
MyFiles,
|
||||||
|
Device,
|
||||||
|
SharedWithMe,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A top-level place in the account's Drive.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DrivePlace {
|
||||||
|
pub kind: PlaceKind,
|
||||||
|
pub name: String,
|
||||||
|
pub uid: NodeUid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One node of a folder listing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DriveEntry {
|
||||||
|
pub uid: NodeUid,
|
||||||
|
pub name: String,
|
||||||
|
pub is_folder: bool,
|
||||||
|
/// Plaintext size, when the uploader recorded one.
|
||||||
|
pub size: Option<i64>,
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a sign-in stands.
|
||||||
|
pub enum SignIn {
|
||||||
|
/// Signed in; the session is stored.
|
||||||
|
Done(Account),
|
||||||
|
/// The account has two-factor authentication: ask for a code and pass it to
|
||||||
|
/// [`PendingSignIn::second_factor`].
|
||||||
|
SecondFactor(PendingSignIn),
|
||||||
|
/// The account has a separate mailbox password: ask for it and pass it to
|
||||||
|
/// [`PendingSignIn::mailbox_password`].
|
||||||
|
MailboxPassword(PendingSignIn),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sign-in waiting on the viewer.
|
||||||
|
pub struct PendingSignIn {
|
||||||
|
secrets: Arc<dyn SecretStore>,
|
||||||
|
session: ProtonApiSession,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingSignIn {
|
||||||
|
/// Submit a second-factor code.
|
||||||
|
///
|
||||||
|
/// A wrong code fails here and leaves nothing behind; the front end starts
|
||||||
|
/// the sign-in over, because Proton does not accept a second try on the
|
||||||
|
/// same half-made session.
|
||||||
|
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
|
||||||
|
self.session
|
||||||
|
.apply_second_factor_code(code.trim())
|
||||||
|
.await
|
||||||
|
.map_err(|error| match error {
|
||||||
|
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
|
||||||
|
Error::SignIn("that code was not accepted".to_owned())
|
||||||
|
}
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
self.after_second_factor().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit the mailbox password, on an account that has one.
|
||||||
|
///
|
||||||
|
/// By reference, unlike the code: a mistyped mailbox password is checked
|
||||||
|
/// on this side, the session survives it, and the viewer can try again
|
||||||
|
/// without starting over.
|
||||||
|
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
|
||||||
|
finish(
|
||||||
|
Arc::clone(&self.secrets),
|
||||||
|
self.session.clone(),
|
||||||
|
mailbox_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn after_second_factor(self) -> Result<SignIn> {
|
||||||
|
if self.session.password_mode() == PasswordMode::Dual {
|
||||||
|
return Ok(SignIn::MailboxPassword(self));
|
||||||
|
}
|
||||||
|
let password = self.password.clone();
|
||||||
|
finish(self.secrets, self.session, &password)
|
||||||
|
.await
|
||||||
|
.map(SignIn::Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed-in account, if any, and signing in and out.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AccountStore {
|
||||||
|
secrets: Arc<dyn SecretStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountStore {
|
||||||
|
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
|
||||||
|
Self { secrets }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address of the stored account, without touching the network.
|
||||||
|
pub fn username(&self) -> Result<Option<String>> {
|
||||||
|
Ok(self.load()?.map(|stored| stored.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start signing in.
|
||||||
|
///
|
||||||
|
/// `verification` is the token from a solved CAPTCHA, after a first try
|
||||||
|
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
|
||||||
|
/// it, because the gated attempt's SRP handshake is spent.
|
||||||
|
pub async fn sign_in(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
verification: Option<HumanVerificationCredential>,
|
||||||
|
) -> Result<SignIn> {
|
||||||
|
let session = ProtonApiSession::begin_verified(
|
||||||
|
client_configuration(),
|
||||||
|
username.trim(),
|
||||||
|
password.as_bytes(),
|
||||||
|
verification.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(classify_sign_in_failure)?;
|
||||||
|
let pending = PendingSignIn {
|
||||||
|
secrets: Arc::clone(&self.secrets),
|
||||||
|
session,
|
||||||
|
password: password.to_owned(),
|
||||||
|
};
|
||||||
|
if pending.session.is_waiting_for_second_factor() {
|
||||||
|
let methods = pending.session.second_factor_methods();
|
||||||
|
if methods.fido2 && !methods.totp {
|
||||||
|
// A security key needs WebAuthn, which this sign-in does not
|
||||||
|
// speak; asking for a code would only fail as a wrong one.
|
||||||
|
pending.session.end().await.ok();
|
||||||
|
return Err(Error::SignIn(SECURITY_KEY_ONLY.to_owned()));
|
||||||
|
}
|
||||||
|
return Ok(SignIn::SecondFactor(pending));
|
||||||
|
}
|
||||||
|
pending.after_second_factor().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored account, resumed, or `None` when nobody is signed in.
|
||||||
|
///
|
||||||
|
/// Makes no request: a session whose refresh token has died is found out
|
||||||
|
/// by the first call that uses it, as [`Error::is_signed_out`].
|
||||||
|
pub fn resume(&self) -> Result<Option<Account>> {
|
||||||
|
let Some(stored) = self.load()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
|
||||||
|
persist_refreshed_tokens(&self.secrets, &session, stored.key_passphrases.clone());
|
||||||
|
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
|
||||||
|
Ok(Some(account(session, drive)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the account: end its session and delete what was stored.
|
||||||
|
///
|
||||||
|
/// The stored session goes even when ending it fails — signing out on a
|
||||||
|
/// plane must still sign out.
|
||||||
|
pub async fn sign_out(&self, account: Option<&Account>) -> Result<()> {
|
||||||
|
if let Some(account) = account {
|
||||||
|
account.end().await;
|
||||||
|
}
|
||||||
|
self.secrets.delete(SESSION_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored session, or `None` for none — or for one this build cannot
|
||||||
|
/// read, such as the shape development builds before 1.2.0 stored, which
|
||||||
|
/// kept the mailbox password. Signing in again is the only fix for either,
|
||||||
|
/// and it replaces what is there.
|
||||||
|
fn load(&self) -> Result<Option<StoredSession>> {
|
||||||
|
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_str(&encoded)
|
||||||
|
.inspect_err(|error| {
|
||||||
|
tracing::warn!(%error, "the stored Proton session is unreadable; sign in again");
|
||||||
|
})
|
||||||
|
.ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock the key chain with `mailbox_password`, store the session, and hand
|
||||||
|
/// back the account.
|
||||||
|
///
|
||||||
|
/// The key passphrases are derived here, while the token still has the scope
|
||||||
|
/// 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,
|
||||||
|
mailbox_password: &str,
|
||||||
|
) -> Result<Account> {
|
||||||
|
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||||
|
probe
|
||||||
|
.account()
|
||||||
|
.unlock()
|
||||||
|
.await
|
||||||
|
.map_err(|error| match error {
|
||||||
|
ProtonError::KeysLocked => {
|
||||||
|
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||||
|
}
|
||||||
|
other => other.into(),
|
||||||
|
})?;
|
||||||
|
let key_passphrases = probe
|
||||||
|
.account()
|
||||||
|
.key_passphrases()
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|(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<_, _>>>()?;
|
||||||
|
|
||||||
|
let stored = StoredSession::capture(
|
||||||
|
&session,
|
||||||
|
session.current_tokens().await,
|
||||||
|
key_passphrases.clone(),
|
||||||
|
);
|
||||||
|
let encoded = serde_json::to_string(&stored)
|
||||||
|
.map_err(|e| Error::Config(format!("serialize the Proton session: {e}")))?;
|
||||||
|
secrets.set(SESSION_KEY, &encoded)?;
|
||||||
|
persist_refreshed_tokens(&secrets, &session, key_passphrases);
|
||||||
|
|
||||||
|
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
|
||||||
|
Ok(account(session, drive))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
|
||||||
|
let drive = drive
|
||||||
|
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
|
||||||
|
// A watch-history file is one small block; the single-request upload
|
||||||
|
// is one failure point where the draft dance is three.
|
||||||
|
.with_small_file_upload(true);
|
||||||
|
Account { session, drive }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write every refreshed token pair back to the secret store.
|
||||||
|
///
|
||||||
|
/// Refresh tokens are single-use: once the HTTP client has spent the stored
|
||||||
|
/// one, only the pair it got back can resume the session.
|
||||||
|
fn persist_refreshed_tokens(
|
||||||
|
secrets: &Arc<dyn SecretStore>,
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
key_passphrases: HashMap<String, String>,
|
||||||
|
) {
|
||||||
|
let secrets = Arc::clone(secrets);
|
||||||
|
let template = session.clone();
|
||||||
|
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||||
|
let stored = StoredSession::capture(&template, tokens, key_passphrases.clone());
|
||||||
|
let written = serde_json::to_string(&stored)
|
||||||
|
.map_err(|e| Error::Config(e.to_string()))
|
||||||
|
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
|
||||||
|
if let Err(error) = written {
|
||||||
|
tracing::warn!(%error, "the refreshed Proton session could not be stored");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// failure, which reads better than an empty verification page.
|
||||||
|
fn classify_sign_in_failure(error: ProtonError) -> Error {
|
||||||
|
if let ProtonError::Api(api) = &error {
|
||||||
|
if let Some(challenge) = api.human_verification()
|
||||||
|
&& challenge.supports_captcha()
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
api.message.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token out of a message posted by Proton's verification page, or `None`
|
||||||
|
/// for anything that is not a completion.
|
||||||
|
///
|
||||||
|
/// The page posts plenty besides — resize requests, readiness pings — and
|
||||||
|
/// taking an unrecognised message for success would retry the sign-in with a
|
||||||
|
/// garbage token. Both spellings of the success type have shipped, and some
|
||||||
|
/// hosts hand the message over stringified twice.
|
||||||
|
pub fn verification_token(raw: &str) -> Option<String> {
|
||||||
|
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
|
||||||
|
if let Some(inner) = value.as_str()
|
||||||
|
&& let Ok(parsed) = serde_json::from_str(inner)
|
||||||
|
{
|
||||||
|
value = parsed;
|
||||||
|
}
|
||||||
|
let kind = value.get("type")?.as_str()?;
|
||||||
|
if !matches!(
|
||||||
|
kind,
|
||||||
|
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = value.get("payload")?.get("token")?.as_str()?;
|
||||||
|
(!token.is_empty()).then(|| token.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MemorySecrets(Mutex<HashMap<String, String>>);
|
||||||
|
|
||||||
|
impl SecretStore for MemorySecrets {
|
||||||
|
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||||
|
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
Ok(self.0.lock().get(key).cloned())
|
||||||
|
}
|
||||||
|
fn delete(&self, key: &str) -> Result<()> {
|
||||||
|
self.0.lock().remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored() -> StoredSession {
|
||||||
|
StoredSession {
|
||||||
|
session_id: "session".to_owned(),
|
||||||
|
username: "viewer@proton.me".to_owned(),
|
||||||
|
user_id: "user".to_owned(),
|
||||||
|
access_token: "access".to_owned(),
|
||||||
|
refresh_token: "refresh".to_owned(),
|
||||||
|
scopes: vec!["drive".to_owned()],
|
||||||
|
password_mode: 2,
|
||||||
|
key_passphrases: HashMap::from([("key-1".to_owned(), "derived".to_owned())]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nobody_is_signed_in_until_a_session_is_stored() {
|
||||||
|
let store = AccountStore::new(Arc::new(MemorySecrets::default()));
|
||||||
|
assert_eq!(store.username().expect("read"), None);
|
||||||
|
assert!(store.resume().expect("resume").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_session_resumes_as_its_account_without_a_request() {
|
||||||
|
let secrets = Arc::new(MemorySecrets::default());
|
||||||
|
secrets
|
||||||
|
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::new(secrets);
|
||||||
|
assert_eq!(
|
||||||
|
store.username().expect("read").as_deref(),
|
||||||
|
Some("viewer@proton.me")
|
||||||
|
);
|
||||||
|
let account = store.resume().expect("resume").expect("signed in");
|
||||||
|
assert_eq!(account.username(), "viewer@proton.me");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn signing_out_forgets_the_stored_session() {
|
||||||
|
let secrets = Arc::new(MemorySecrets::default());
|
||||||
|
secrets
|
||||||
|
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::new(secrets);
|
||||||
|
store.sign_out(None).await.expect("sign out");
|
||||||
|
assert_eq!(store.username().expect("read"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stored_session_holds_no_password() {
|
||||||
|
let encoded = serde_json::to_string(&stored()).unwrap();
|
||||||
|
assert!(!encoded.contains("password\""), "{encoded}");
|
||||||
|
assert!(!encoded.contains("key_salts"), "{encoded}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_session_stored_by_an_older_build_reads_as_signed_out() {
|
||||||
|
let secrets = Arc::new(MemorySecrets::default());
|
||||||
|
secrets
|
||||||
|
.set(
|
||||||
|
SESSION_KEY,
|
||||||
|
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
|
||||||
|
"refresh_token":"r","scopes":[],"password_mode":1,
|
||||||
|
"mailbox_password":"hunter2","key_salts":[]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::new(secrets);
|
||||||
|
assert_eq!(store.username().expect("read"), None);
|
||||||
|
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();
|
||||||
|
assert_eq!(params.password_mode, PasswordMode::Dual);
|
||||||
|
assert!(!params.is_waiting_for_second_factor_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_verification_completion_yields_its_token() {
|
||||||
|
let raw =
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
|
||||||
|
assert_eq!(verification_token(raw).as_deref(), Some("tok-1"));
|
||||||
|
let twice = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
|
||||||
|
assert_eq!(verification_token(twice).as_deref(), Some("tok-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_that_are_not_a_verification_completion_are_ignored() {
|
||||||
|
for raw in [
|
||||||
|
r#"{"type":"resize","payload":{"height":400}}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
|
||||||
|
"not json",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(verification_token(raw).is_none(), "accepted: {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,30 @@ impl Accent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What shape the library's grid is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Tiles {
|
||||||
|
/// 2:3 covers. The default, because the art a provider has for every
|
||||||
|
/// title is a cover; a 16:9 still exists for fewer of them, and a grid of
|
||||||
|
/// letterboxed covers was the desktop's weakest page.
|
||||||
|
#[default]
|
||||||
|
Posters,
|
||||||
|
/// 16:9 stills: the provider's fanart, else a frame from the file.
|
||||||
|
Stills,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tiles {
|
||||||
|
pub const ALL: [Self; 2] = [Self::Posters, Self::Stills];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Posters => "Posters",
|
||||||
|
Self::Stills => "Stills",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The whole of the choice.
|
/// The whole of the choice.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
// An older or hand-edited file is missing fields rather than invalid.
|
// An older or hand-edited file is missing fields rather than invalid.
|
||||||
@@ -138,6 +162,10 @@ pub struct Appearance {
|
|||||||
/// slow ramp across a wide bar bands visibly, and flat is better than
|
/// slow ramp across a wide bar bands visibly, and flat is better than
|
||||||
/// striped.
|
/// striped.
|
||||||
pub gradients: bool,
|
pub gradients: bool,
|
||||||
|
/// The library grid's shape.
|
||||||
|
pub tiles: Tiles,
|
||||||
|
/// Which name a title goes by.
|
||||||
|
pub names: crate::metadata::TitleNames,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Appearance {
|
impl Default for Appearance {
|
||||||
@@ -146,6 +174,8 @@ impl Default for Appearance {
|
|||||||
flavor: Flavor::default(),
|
flavor: Flavor::default(),
|
||||||
accent: Accent::default(),
|
accent: Accent::default(),
|
||||||
gradients: true,
|
gradients: true,
|
||||||
|
tiles: Tiles::default(),
|
||||||
|
names: crate::metadata::TitleNames::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,6 +645,7 @@ mod tests {
|
|||||||
flavor: Flavor::Mocha,
|
flavor: Flavor::Mocha,
|
||||||
accent: Accent::PinkSky,
|
accent: Accent::PinkSky,
|
||||||
gradients: false,
|
gradients: false,
|
||||||
|
..Appearance::default()
|
||||||
};
|
};
|
||||||
save(&dirs, &appearance).unwrap();
|
save(&dirs, &appearance).unwrap();
|
||||||
assert_eq!(load(&dirs).unwrap(), appearance);
|
assert_eq!(load(&dirs).unwrap(), appearance);
|
||||||
@@ -652,6 +683,7 @@ mod tests {
|
|||||||
flavor,
|
flavor,
|
||||||
accent,
|
accent,
|
||||||
gradients: true,
|
gradients: true,
|
||||||
|
..Appearance::default()
|
||||||
});
|
});
|
||||||
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -681,6 +713,7 @@ mod tests {
|
|||||||
flavor: Flavor::Persona5,
|
flavor: Flavor::Persona5,
|
||||||
accent: Accent::Red,
|
accent: Accent::Red,
|
||||||
gradients: false,
|
gradients: false,
|
||||||
|
..Appearance::default()
|
||||||
});
|
});
|
||||||
assert_eq!(palette.accent, PERSONA5.red);
|
assert_eq!(palette.accent, PERSONA5.red);
|
||||||
assert!(!palette.light);
|
assert!(!palette.light);
|
||||||
@@ -697,6 +730,7 @@ mod tests {
|
|||||||
flavor,
|
flavor,
|
||||||
accent: Accent::Red,
|
accent: Accent::Red,
|
||||||
gradients: true,
|
gradients: true,
|
||||||
|
..Appearance::default()
|
||||||
});
|
});
|
||||||
// The accent is the flavour's red — deepened on the light flavour,
|
// The accent is the flavour's red — deepened on the light flavour,
|
||||||
// where it would otherwise be too bright to put a label on.
|
// where it would otherwise be too bright to put a label on.
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
//! Finding and ordering titles: what both library grids show, and in what
|
||||||
|
//! order.
|
||||||
|
//!
|
||||||
|
//! Here rather than in a front end so the desktop and Android agree: the same
|
||||||
|
//! search finds the same titles, a franchise folds into the same tile, and
|
||||||
|
//! "Newest" means the same thing on both.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
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, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Sort {
|
||||||
|
/// By the name shown, ignoring a leading article.
|
||||||
|
#[default]
|
||||||
|
Name,
|
||||||
|
/// Most recently played first.
|
||||||
|
Recent,
|
||||||
|
/// Most recently given a new file first.
|
||||||
|
Added,
|
||||||
|
/// Newest first by when it aired, undated last.
|
||||||
|
Release,
|
||||||
|
/// Best rated first, unrated last.
|
||||||
|
Rating,
|
||||||
|
/// Most followed at the provider first.
|
||||||
|
Popularity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sort {
|
||||||
|
pub const ALL: [Self; 6] = [
|
||||||
|
Self::Name,
|
||||||
|
Self::Recent,
|
||||||
|
Self::Added,
|
||||||
|
Self::Release,
|
||||||
|
Self::Rating,
|
||||||
|
Self::Popularity,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Name => "A – Z",
|
||||||
|
Self::Recent => "Recently watched",
|
||||||
|
Self::Added => "Recently added",
|
||||||
|
Self::Release => "Newest",
|
||||||
|
Self::Rating => "Highest rated",
|
||||||
|
Self::Popularity => "Most popular",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>(
|
||||||
|
title: &'a Title,
|
||||||
|
metadata: &'a HashMap<String, MetadataRecord>,
|
||||||
|
names: TitleNames,
|
||||||
|
) -> &'a str {
|
||||||
|
metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|found| found.display_name(names))
|
||||||
|
.unwrap_or(&title.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The titles `query` finds, in library order.
|
||||||
|
///
|
||||||
|
/// By the share's name and every filename under it — a release group, an
|
||||||
|
/// episode name — and by every name the provider gave: a folder called
|
||||||
|
/// `Sousou no Frieren` is found by typing "Frieren: Beyond".
|
||||||
|
pub fn search<'a>(
|
||||||
|
library: &'a Library,
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
query: &str,
|
||||||
|
) -> Vec<&'a Title> {
|
||||||
|
let needle = query.trim().to_lowercase();
|
||||||
|
let by_files: HashSet<*const Title> = library
|
||||||
|
.search(query)
|
||||||
|
.into_iter()
|
||||||
|
.map(|title| title as *const Title)
|
||||||
|
.collect();
|
||||||
|
library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| {
|
||||||
|
by_files.contains(&(*title as *const Title))
|
||||||
|
|| metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.is_some_and(|found| {
|
||||||
|
std::iter::once(found.name.as_str())
|
||||||
|
.chain(found.original_name.as_deref())
|
||||||
|
.chain(
|
||||||
|
found
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|details| details.romaji.as_deref()),
|
||||||
|
)
|
||||||
|
.any(|name| name.to_lowercase().contains(&needle))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to order a grid.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Order<'a> {
|
||||||
|
pub sort: Sort,
|
||||||
|
/// One tile per franchise rather than one per title.
|
||||||
|
pub grouped: bool,
|
||||||
|
pub names: TitleNames,
|
||||||
|
pub metadata: &'a HashMap<String, MetadataRecord>,
|
||||||
|
/// When each title last gained a file, by key — see
|
||||||
|
/// [`Library::added_at`].
|
||||||
|
pub added: &'a HashMap<String, i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tile: the title drawn, and every title it stands for — itself
|
||||||
|
/// included, in release order — as indices into what was arranged.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Tile {
|
||||||
|
pub lead: usize,
|
||||||
|
pub members: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `titles` as tiles, in `order`.
|
||||||
|
///
|
||||||
|
/// Every sort is stable over the name order, so ties — and the undated,
|
||||||
|
/// unrated and unplayed — keep their alphabetical order.
|
||||||
|
pub fn arrange<'t>(titles: &[&'t Title], order: Order<'_>) -> Vec<Tile> {
|
||||||
|
let metadata = order.metadata;
|
||||||
|
let found = |title: &Title| {
|
||||||
|
metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut by_name: Vec<usize> = (0..titles.len()).collect();
|
||||||
|
by_name
|
||||||
|
.sort_by_cached_key(|&index| sort_name(display_name(titles[index], metadata, order.names)));
|
||||||
|
let sorted: Vec<&Title> = by_name.iter().map(|&index| titles[index]).collect();
|
||||||
|
|
||||||
|
let mut tiles: Vec<Tile> = if order.grouped {
|
||||||
|
crate::franchise::group(&sorted, metadata)
|
||||||
|
.into_iter()
|
||||||
|
.map(|franchise| Tile {
|
||||||
|
lead: by_name[franchise.lead(&sorted)],
|
||||||
|
members: franchise
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|&member| by_name[member])
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
by_name
|
||||||
|
.iter()
|
||||||
|
.map(|&index| Tile {
|
||||||
|
lead: index,
|
||||||
|
members: vec![index],
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// A folded franchise files under the name on its tile — the lead's — and
|
||||||
|
// not under whichever member sorted first: "Made in Abyss" belongs at M
|
||||||
|
// even when its film is called "Dawn of the Deep Soul".
|
||||||
|
tiles.sort_by_cached_key(|tile| {
|
||||||
|
sort_name(display_name(titles[tile.lead], metadata, order.names))
|
||||||
|
});
|
||||||
|
|
||||||
|
let members = |tile: &Tile| -> Vec<&'t Title> {
|
||||||
|
tile.members.iter().map(|&index| titles[index]).collect()
|
||||||
|
};
|
||||||
|
match order.sort {
|
||||||
|
Sort::Name => {}
|
||||||
|
Sort::Recent => tiles.sort_by_key(|tile| {
|
||||||
|
std::cmp::Reverse(members(tile).iter().map(|title| title.last_played()).max())
|
||||||
|
}),
|
||||||
|
Sort::Added => tiles.sort_by_key(|tile| {
|
||||||
|
std::cmp::Reverse(
|
||||||
|
members(tile)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|title| order.added.get(&title.key))
|
||||||
|
.max()
|
||||||
|
.copied(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
Sort::Release => tiles.sort_by_cached_key(|tile| {
|
||||||
|
std::cmp::Reverse(
|
||||||
|
members(tile)
|
||||||
|
.iter()
|
||||||
|
.map(|title| crate::franchise::release_key(title, metadata))
|
||||||
|
// `~` is how the key says "undated"; it sorts last here
|
||||||
|
// rather than first.
|
||||||
|
.filter(|key| key != "~")
|
||||||
|
.max(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
Sort::Rating => tiles.sort_by_key(|tile| {
|
||||||
|
std::cmp::Reverse(
|
||||||
|
found(titles[tile.lead])
|
||||||
|
.and_then(|found| found.rating)
|
||||||
|
.map(|rating| (rating * 100.0) as i64),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
Sort::Popularity => tiles.sort_by_key(|tile| {
|
||||||
|
std::cmp::Reverse(
|
||||||
|
found(titles[tile.lead])
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| details.popularity),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::TitleKind;
|
||||||
|
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
|
||||||
|
|
||||||
|
fn title(key: &str, name: &str, kind: TitleKind) -> Title {
|
||||||
|
Title {
|
||||||
|
key: key.into(),
|
||||||
|
name: name.into(),
|
||||||
|
year: None,
|
||||||
|
kind,
|
||||||
|
seasons: Vec::new(),
|
||||||
|
share_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matched(
|
||||||
|
key: &str,
|
||||||
|
id: &str,
|
||||||
|
english: &str,
|
||||||
|
start: &str,
|
||||||
|
rating: f32,
|
||||||
|
related: &[&str],
|
||||||
|
) -> (String, MetadataRecord) {
|
||||||
|
(
|
||||||
|
key.into(),
|
||||||
|
MetadataRecord {
|
||||||
|
title_key: key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: id.into(),
|
||||||
|
name: english.into(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Series,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: None,
|
||||||
|
rating: Some(rating),
|
||||||
|
genres: Vec::new(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: Some(TitleDetails {
|
||||||
|
romaji: Some(key.into()),
|
||||||
|
start_date: Some(start.into()),
|
||||||
|
related: related.iter().map(|id| id.to_string()).collect(),
|
||||||
|
..TitleDetails::default()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (Vec<Title>, HashMap<String, MetadataRecord>) {
|
||||||
|
let titles = vec![
|
||||||
|
title("abyss film", "Made in Abyss Dawn", TitleKind::Film),
|
||||||
|
title("made in abyss", "Made in Abyss", TitleKind::Series),
|
||||||
|
title("sousou no frieren", "Sousou no Frieren", TitleKind::Series),
|
||||||
|
];
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
matched(
|
||||||
|
"abyss film",
|
||||||
|
"2",
|
||||||
|
"Dawn of the Deep Soul",
|
||||||
|
"2020-01-17",
|
||||||
|
8.5,
|
||||||
|
&["1"],
|
||||||
|
),
|
||||||
|
matched(
|
||||||
|
"made in abyss",
|
||||||
|
"1",
|
||||||
|
"Made in Abyss",
|
||||||
|
"2017-07-07",
|
||||||
|
8.4,
|
||||||
|
&["2"],
|
||||||
|
),
|
||||||
|
matched(
|
||||||
|
"sousou no frieren",
|
||||||
|
"3",
|
||||||
|
"Frieren: Beyond Journey's End",
|
||||||
|
"2023-09-29",
|
||||||
|
9.1,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
(titles, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keys(tiles: &[Tile], titles: &[&Title]) -> Vec<String> {
|
||||||
|
tiles
|
||||||
|
.iter()
|
||||||
|
.map(|tile| titles[tile.lead].key.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_franchise_folds_into_its_series_and_sorts_by_the_name_shown() {
|
||||||
|
let (owned, metadata) = fixture();
|
||||||
|
let titles: Vec<&Title> = owned.iter().collect();
|
||||||
|
let added = HashMap::new();
|
||||||
|
let order = Order {
|
||||||
|
sort: Sort::Name,
|
||||||
|
grouped: true,
|
||||||
|
names: TitleNames::English,
|
||||||
|
metadata: &metadata,
|
||||||
|
added: &added,
|
||||||
|
};
|
||||||
|
let tiles = arrange(&titles, order);
|
||||||
|
// "Frieren" before "Made in Abyss" in English; the film is folded.
|
||||||
|
assert_eq!(
|
||||||
|
keys(&tiles, &titles),
|
||||||
|
vec!["sousou no frieren", "made in abyss"]
|
||||||
|
);
|
||||||
|
assert_eq!(tiles[1].members, vec![1, 0]);
|
||||||
|
|
||||||
|
let tiles = arrange(
|
||||||
|
&titles,
|
||||||
|
Order {
|
||||||
|
names: TitleNames::Library,
|
||||||
|
grouped: false,
|
||||||
|
..order
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
keys(&tiles, &titles),
|
||||||
|
vec!["made in abyss", "abyss film", "sousou no frieren"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_and_best_rated_read_the_provider_and_put_the_unknown_last() {
|
||||||
|
let (mut owned, metadata) = fixture();
|
||||||
|
owned.push(title(
|
||||||
|
"game of thrones",
|
||||||
|
"Game of Thrones",
|
||||||
|
TitleKind::Series,
|
||||||
|
));
|
||||||
|
let titles: Vec<&Title> = owned.iter().collect();
|
||||||
|
let added = HashMap::new();
|
||||||
|
let order = Order {
|
||||||
|
sort: Sort::Release,
|
||||||
|
grouped: true,
|
||||||
|
names: TitleNames::Library,
|
||||||
|
metadata: &metadata,
|
||||||
|
added: &added,
|
||||||
|
};
|
||||||
|
// The Made in Abyss franchise dates from its newest part, 2020.
|
||||||
|
assert_eq!(
|
||||||
|
keys(&arrange(&titles, order), &titles),
|
||||||
|
vec!["sousou no frieren", "made in abyss", "game of thrones"]
|
||||||
|
);
|
||||||
|
let order = Order {
|
||||||
|
sort: Sort::Rating,
|
||||||
|
..order
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
keys(&arrange(&titles, order), &titles)
|
||||||
|
.last()
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("game of thrones")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_finds_a_title_by_any_name_the_provider_gave_it() {
|
||||||
|
let (owned, metadata) = fixture();
|
||||||
|
let library = Library { titles: owned };
|
||||||
|
let found = search(&library, &metadata, "beyond journey");
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,12 @@ impl AppDirs {
|
|||||||
self.config.join("shares.json")
|
self.config.join("shares.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This installation's identity for watch-history sync. See
|
||||||
|
/// [`crate::sync`].
|
||||||
|
pub fn sync_file(&self) -> PathBuf {
|
||||||
|
self.config.join("sync.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// The catalog database.
|
/// The catalog database.
|
||||||
pub fn catalog_db(&self) -> PathBuf {
|
pub fn catalog_db(&self) -> PathBuf {
|
||||||
self.data.join("catalog.db")
|
self.data.join("catalog.db")
|
||||||
|
|||||||
@@ -30,6 +30,75 @@ pub enum Error {
|
|||||||
/// The caller asked for something that does not exist or does not apply.
|
/// The caller asked for something that does not exist or does not apply.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
|
||||||
|
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
|
||||||
|
/// and signs in again with the token it yields.
|
||||||
|
#[error("Proton wants to confirm you are human before signing in")]
|
||||||
|
HumanVerification(Box<proton_sdk::api::HumanVerification>),
|
||||||
|
|
||||||
|
/// No account is signed in, or its session can no longer be refreshed and
|
||||||
|
/// the viewer has to sign in again.
|
||||||
|
#[error("{0}")]
|
||||||
|
SignedOut(String),
|
||||||
|
|
||||||
|
/// Signing in failed for a reason the viewer can act on, such as a wrong
|
||||||
|
/// 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, 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()
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -40,3 +109,31 @@ impl From<keyring::Error> for Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("connection closed before message completed")]
|
||||||
|
struct Closed;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("client error (SendRequest)")]
|
||||||
|
struct SendRequest(#[source] Closed);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_described_with_the_causes_its_own_text_leaves_out() {
|
||||||
|
let error = Error::Io(std::io::Error::other(SendRequest(Closed)));
|
||||||
|
assert_eq!(
|
||||||
|
error.describe(),
|
||||||
|
"io: client error (SendRequest): connection closed before message completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,290 @@
|
|||||||
|
//! Which titles in a library are one story.
|
||||||
|
//!
|
||||||
|
//! A share files a franchise however its uploader did: Made in Abyss as a
|
||||||
|
//! series folder and three film folders, Evangelion as a series, two films and
|
||||||
|
//! a folder of four more. The provider knows they belong together — AniList
|
||||||
|
//! records prequels, sequels, side stories, recaps and retellings as relations
|
||||||
|
//! — so the library can show one tile for the lot and the parts on its page.
|
||||||
|
//!
|
||||||
|
//! Grouping is by shared provider id, not by name. Two titles are one
|
||||||
|
//! franchise when one names the other as related, or when both name the same
|
||||||
|
//! entry: Mushishi's *Suzu no Shizuku* film relates to *Zoku Shou 2*, which the
|
||||||
|
//! library does not hold as a title of its own but which the series' episode
|
||||||
|
//! walk passed through (see [`crate::metadata::TitleDetails::chain`]).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::library::{Title, TitleKind};
|
||||||
|
use crate::metadata::MetadataRecord;
|
||||||
|
|
||||||
|
/// One franchise: indices into the titles it was built from, in release order.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Franchise {
|
||||||
|
pub members: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Franchise {
|
||||||
|
/// The member that stands for the rest: the first series to air, or the
|
||||||
|
/// first film when there is no series. What a viewer means by "Made in
|
||||||
|
/// Abyss" is the show, not the recap film that came out after it.
|
||||||
|
pub fn lead(&self, titles: &[&Title]) -> usize {
|
||||||
|
self.members
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|&index| titles[index].kind == TitleKind::Series)
|
||||||
|
.unwrap_or(self.members[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `titles` into franchises, every title in exactly one.
|
||||||
|
///
|
||||||
|
/// Titles with no match are franchises of one. The groups come back in the
|
||||||
|
/// order of their first member in `titles`, so a sorted list stays sorted.
|
||||||
|
pub fn group(titles: &[&Title], metadata: &HashMap<String, MetadataRecord>) -> Vec<Franchise> {
|
||||||
|
let mut sets = DisjointSets::new(titles.len());
|
||||||
|
// The first title to name each provider id, for later titles naming it too.
|
||||||
|
let mut named: HashMap<(crate::metadata::ProviderId, &str), usize> = HashMap::new();
|
||||||
|
for (index, title) in titles.iter().enumerate() {
|
||||||
|
let Some(found) = metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let details = found.details.as_ref();
|
||||||
|
let ids = std::iter::once(found.remote_id.as_str())
|
||||||
|
.chain(details.into_iter().flat_map(|details| {
|
||||||
|
details
|
||||||
|
.related
|
||||||
|
.iter()
|
||||||
|
.chain(&details.chain)
|
||||||
|
.map(String::as_str)
|
||||||
|
}))
|
||||||
|
.filter(|id| !id.is_empty());
|
||||||
|
for id in ids {
|
||||||
|
match named.get(&(found.provider, id)) {
|
||||||
|
Some(&other) => sets.union(index, other),
|
||||||
|
None => {
|
||||||
|
named.insert((found.provider, id), index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut groups: Vec<Franchise> = Vec::new();
|
||||||
|
let mut slot: HashMap<usize, usize> = HashMap::new();
|
||||||
|
for index in 0..titles.len() {
|
||||||
|
let root = sets.find(index);
|
||||||
|
match slot.get(&root) {
|
||||||
|
Some(&at) => groups[at].members.push(index),
|
||||||
|
None => {
|
||||||
|
slot.insert(root, groups.len());
|
||||||
|
groups.push(Franchise {
|
||||||
|
members: vec![index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for franchise in &mut groups {
|
||||||
|
franchise
|
||||||
|
.members
|
||||||
|
.sort_by_cached_key(|&index| release_key(titles[index], metadata));
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What release order sorts on: the provider's start date, else the year the
|
||||||
|
/// files state, else last.
|
||||||
|
pub fn release_key(title: &Title, metadata: &HashMap<String, MetadataRecord>) -> String {
|
||||||
|
let found = metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref());
|
||||||
|
found
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
.and_then(|details| details.start_date.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
found
|
||||||
|
.and_then(|found| found.year)
|
||||||
|
.map(|year| year.to_string())
|
||||||
|
})
|
||||||
|
.or_else(|| title.year.map(|year| year.to_string()))
|
||||||
|
// `~` sorts after every digit: undated goes last.
|
||||||
|
.unwrap_or_else(|| "~".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DisjointSets {
|
||||||
|
parent: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisjointSets {
|
||||||
|
fn new(size: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
parent: (0..size).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(&mut self, mut index: usize) -> usize {
|
||||||
|
while self.parent[index] != index {
|
||||||
|
self.parent[index] = self.parent[self.parent[index]];
|
||||||
|
index = self.parent[index];
|
||||||
|
}
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
fn union(&mut self, left: usize, right: usize) {
|
||||||
|
let (left, right) = (self.find(left), self.find(right));
|
||||||
|
if left != right {
|
||||||
|
// The earlier title stays the root, which keeps `group` stable.
|
||||||
|
let (root, child) = (left.min(right), left.max(right));
|
||||||
|
self.parent[child] = root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
|
||||||
|
|
||||||
|
fn title(key: &str, kind: TitleKind) -> Title {
|
||||||
|
Title {
|
||||||
|
key: key.into(),
|
||||||
|
name: key.into(),
|
||||||
|
year: None,
|
||||||
|
kind,
|
||||||
|
seasons: Vec::new(),
|
||||||
|
share_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matched(
|
||||||
|
key: &str,
|
||||||
|
id: &str,
|
||||||
|
start: &str,
|
||||||
|
related: &[&str],
|
||||||
|
chain: &[&str],
|
||||||
|
) -> MetadataRecord {
|
||||||
|
MetadataRecord {
|
||||||
|
title_key: key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: id.into(),
|
||||||
|
name: key.into(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Series,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: None,
|
||||||
|
rating: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: Some(TitleDetails {
|
||||||
|
start_date: Some(start.into()),
|
||||||
|
related: related.iter().map(|id| id.to_string()).collect(),
|
||||||
|
chain: chain.iter().map(|id| id.to_string()).collect(),
|
||||||
|
..TitleDetails::default()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keys(groups: &[Franchise], titles: &[&Title]) -> Vec<Vec<String>> {
|
||||||
|
groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
group
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|&index| titles[index].key.clone())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Made in Abyss: the films name the series, the series names the films.
|
||||||
|
/// Paprika names nothing in the library and stays alone.
|
||||||
|
#[test]
|
||||||
|
fn titles_that_name_each_other_are_one_franchise_in_release_order() {
|
||||||
|
let owned = [
|
||||||
|
title("dawn of the deep soul", TitleKind::Film),
|
||||||
|
title("made in abyss", TitleKind::Series),
|
||||||
|
title("paprika", TitleKind::Film),
|
||||||
|
];
|
||||||
|
let titles: Vec<&Title> = owned.iter().collect();
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
(
|
||||||
|
"dawn of the deep soul".to_string(),
|
||||||
|
matched(
|
||||||
|
"dawn of the deep soul",
|
||||||
|
"100643",
|
||||||
|
"2020-01-17",
|
||||||
|
&["97986"],
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"made in abyss".to_string(),
|
||||||
|
matched("made in abyss", "97986", "2017-07-07", &["100643"], &[]),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"paprika".to_string(),
|
||||||
|
matched("paprika", "1943", "2006-11-25", &[], &[]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let groups = group(&titles, &metadata);
|
||||||
|
assert_eq!(
|
||||||
|
keys(&groups, &titles),
|
||||||
|
vec![
|
||||||
|
vec!["made in abyss", "dawn of the deep soul"],
|
||||||
|
vec!["paprika"]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(titles[groups[0].lead(&titles)].key, "made in abyss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mushishi: the special names an entry the series only reached by
|
||||||
|
/// walking its sequels for episodes. That is still the same story.
|
||||||
|
#[test]
|
||||||
|
fn an_entry_the_series_walked_through_joins_what_names_it() {
|
||||||
|
let owned = [
|
||||||
|
title("mushishi", TitleKind::Series),
|
||||||
|
title("suzu no shizuku", TitleKind::Film),
|
||||||
|
];
|
||||||
|
let titles: Vec<&Title> = owned.iter().collect();
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
(
|
||||||
|
"mushishi".to_string(),
|
||||||
|
matched(
|
||||||
|
"mushishi",
|
||||||
|
"457",
|
||||||
|
"2005-10-23",
|
||||||
|
&["20526"],
|
||||||
|
&["457", "20595", "20751"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"suzu no shizuku".to_string(),
|
||||||
|
matched("suzu no shizuku", "21008", "2015-05-16", &["20751"], &[]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert_eq!(group(&titles, &metadata).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing matched, nothing to group by — and two unmatched titles are
|
||||||
|
/// not one franchise because they share an empty id.
|
||||||
|
#[test]
|
||||||
|
fn unmatched_titles_stand_alone() {
|
||||||
|
let owned = [
|
||||||
|
title("game of thrones", TitleKind::Series),
|
||||||
|
title("something else", TitleKind::Series),
|
||||||
|
];
|
||||||
|
let titles: Vec<&Title> = owned.iter().collect();
|
||||||
|
assert_eq!(group(&titles, &HashMap::new()).len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||