Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3638e0e5df | ||
|
|
aef182e7e8 | ||
|
|
6853725ca0 | ||
|
|
4c80076762 | ||
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 | ||
|
|
0563c91b9e | ||
|
|
3b9f4b549c | ||
|
|
d5c819ee94 | ||
|
|
153859a735 | ||
|
|
1c61aadb22 | ||
|
|
8d40b211ed | ||
|
|
6229a59561 | ||
|
|
cfd654b267 | ||
|
|
0bc7cba428 | ||
|
|
65ecc099b6 | ||
|
|
65a6ae9373 | ||
|
|
2fbbd0e090 | ||
|
|
eee45bc8f8 | ||
|
|
1c8df80c27 | ||
|
|
5673a44310 | ||
|
|
c63f08ac91 | ||
|
|
f2e69dc852 | ||
|
|
8a1807c262 | ||
|
|
47b33cdb91 | ||
|
|
2d7d895dda | ||
|
|
0d33799199 | ||
|
|
2ac906cf37 | ||
|
|
cf065fc5fe | ||
|
|
55ee412a1b | ||
|
|
2275df40a8 | ||
|
|
78ffafe21a |
No files matched your search
@@ -3,7 +3,15 @@ name: Android
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['v*']
|
# A `v*` tag reaches this workflow through release.yml, which calls it and
|
||||||
|
# publishes the signed APK. Triggering on the tag here as well would build
|
||||||
|
# the release twice.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
signed_release:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'android/**'
|
- 'android/**'
|
||||||
@@ -74,14 +82,23 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||||
|
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||||
|
# build needs are installed explicitly below.
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Install native build prerequisites
|
- name: Install native build prerequisites
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends \
|
sudo apt-get install --no-install-recommends \
|
||||||
autoconf automake build-essential cmake curl git gperf libtool \
|
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||||
|
# mpv-android revision builds requires >= 1.11.
|
||||||
|
pipx install meson==1.12.1
|
||||||
|
|
||||||
- name: Install Android SDK and NDK
|
- name: Install Android SDK and NDK
|
||||||
run: >-
|
run: >-
|
||||||
@@ -90,12 +107,53 @@ 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
|
||||||
|
|
||||||
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
|
# `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:
|
||||||
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
|
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
|
||||||
|
# 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
|
||||||
|
# and saved in steps of its own, so a later failure in Gradle does not
|
||||||
|
# discard it the way a post-job cache save would.
|
||||||
|
- id: libmpv
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||||
|
|
||||||
|
- name: Build libmpv
|
||||||
|
run: bash scripts/build-libmpv-android.sh
|
||||||
|
|
||||||
|
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
@@ -124,9 +182,15 @@ 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: false
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -141,14 +205,23 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||||
|
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||||
|
# build needs are installed explicitly below.
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Install native build prerequisites
|
- name: Install native build prerequisites
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends \
|
sudo apt-get install --no-install-recommends \
|
||||||
autoconf automake build-essential cmake curl git gperf libtool \
|
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||||
|
# mpv-android revision builds requires >= 1.11.
|
||||||
|
pipx install meson==1.12.1
|
||||||
|
|
||||||
- name: Install Android SDK and NDK
|
- name: Install Android SDK and NDK
|
||||||
run: >-
|
run: >-
|
||||||
@@ -157,12 +230,53 @@ 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
|
||||||
|
|
||||||
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
|
# `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:
|
||||||
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
|
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
|
||||||
|
# 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
|
||||||
|
# and saved in steps of its own, so a later failure in Gradle does not
|
||||||
|
# discard it the way a post-job cache save would.
|
||||||
|
- id: libmpv
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||||
|
|
||||||
|
- name: Build libmpv
|
||||||
|
run: bash scripts/build-libmpv-android.sh
|
||||||
|
|
||||||
|
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
@@ -188,10 +302,30 @@ 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
|
||||||
|
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||||
|
- name: Stage release files
|
||||||
|
run: |
|
||||||
|
version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||||
|
mkdir -p dist/android
|
||||||
|
cp android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
"dist/android/proton-stream-$version-android.apk"
|
||||||
|
cp android/app/build/generated/mpv/mpv-android-source-*.tar.gz dist/android/
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-signed
|
name: android
|
||||||
path: |
|
path: dist/android/*
|
||||||
android/app/build/outputs/apk/release/*.apk
|
if-no-files-found: error
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-bundle
|
||||||
|
path: android/app/build/outputs/bundle/release/*.aab
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -20,9 +20,6 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Android remains a workflow artifact only until the native libmpv adapter,
|
|
||||||
# corresponding-source bundle, and on-device verification are complete.
|
|
||||||
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
@@ -60,11 +57,6 @@ jobs:
|
|||||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
||||||
|
|
||||||
- name: Android publication remains blocked
|
|
||||||
run: |
|
|
||||||
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
|
|
||||||
echo "Android APK/AAB publication is blocked until native playback is release-ready."
|
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
name: Linux artifacts
|
name: Linux artifacts
|
||||||
needs: verify
|
needs: verify
|
||||||
@@ -148,25 +140,38 @@ jobs:
|
|||||||
dist/*.msi
|
dist/*.msi
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The signed APK plus the corresponding source of the libmpv it bundles. The
|
||||||
|
# keystore and its passwords come from the ANDROID_RELEASE_* secrets.
|
||||||
|
android:
|
||||||
|
name: Android
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/android.yml
|
||||||
|
with:
|
||||||
|
signed_release: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish desktop artifacts
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows]
|
needs: [linux, windows, android]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
# By name: the Android workflow also uploads the AAB, which is for Play
|
||||||
|
# and does not belong on the release.
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
|
name: linux
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Refuse mobile artifacts while Android publishing is blocked
|
|
||||||
run: |
|
|
||||||
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
|
|
||||||
echo "::error::Android publishing is disabled while native playback remains incomplete"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4359,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4378,7 +4378,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4409,7 +4409,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4427,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4445,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4458,7 +4458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -4473,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Android plan
|
||||||
|
|
||||||
|
Where the Android client stands against the desktop app, and what to change to
|
||||||
|
make it feel like a modern streaming app rather than a settings form with a
|
||||||
|
player attached. Written 2026-10-03 from a source review and four screenshots
|
||||||
|
(Library, Title, Shares, Settings) taken on a phone running v0.5.0.
|
||||||
|
|
||||||
|
Android last caught up with the desktop on 2026-08-10 (`4ef6225`); the desktop
|
||||||
|
gained about twenty features on 2026-10-02 that Android does not have.
|
||||||
|
|
||||||
|
## Status (2026-10-03)
|
||||||
|
|
||||||
|
Done: section 0, every parity item, and every recommendation in section 2 —
|
||||||
|
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
|
||||||
|
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.
|
||||||
|
|
||||||
|
Two departures from the text below. The tablet layout is a two-pane `Row`
|
||||||
|
rather than `ListDetailPaneScaffold`, which would have added the
|
||||||
|
`adaptive-layout` artifact for one split. Pull to refresh shows the
|
||||||
|
indicator while a crawl runs, but not the crawl's progress.
|
||||||
|
|
||||||
|
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||||
|
tests) passes, and Android lint runs in CI.
|
||||||
|
|
||||||
|
## 0. CI (done)
|
||||||
|
|
||||||
|
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||||
|
`pstr-core` pulls `keyring` → `dbus-secret-service` → `libdbus-sys`, which needs
|
||||||
|
`libdbus-1-dev` on the host. Because no run ever succeeded, no cache was ever
|
||||||
|
saved, so each run started from nothing.
|
||||||
|
|
||||||
|
- `libdbus-1-dev` added to both jobs' apt install.
|
||||||
|
- `Swatinem/rust-cache` shares one key between the two jobs and saves on failure.
|
||||||
|
- `android/app/build/generated/mpv` is cached on the hash of
|
||||||
|
`scripts/build-libmpv-android.sh`; the script writes a `STAMP` (revision plus
|
||||||
|
its own hash) last and skips the whole libmpv build when the stamp matches.
|
||||||
|
|
||||||
|
Later runs also needed meson 1.12.1 from PyPI (noble's 1.3.2 is too old for
|
||||||
|
the pinned fontconfig), libmpv built and cached in its own steps so a later
|
||||||
|
failure keeps it, and lint's `OldTargetApi` made informational. A cached run
|
||||||
|
restores libmpv in under a minute.
|
||||||
|
|
||||||
|
## 1. Desktop parity
|
||||||
|
|
||||||
|
Ordered by value on a phone.
|
||||||
|
|
||||||
|
1. **Open share links from outside the app.** The manifest has only the
|
||||||
|
launcher intent. Add an `ACTION_VIEW` filter for `drive.proton.me/urls/*` and
|
||||||
|
an `ACTION_SEND` (`text/plain`) filter; both open the add-share dialog with
|
||||||
|
the link filled in. Desktop: `4a54af3`, `53a1d0a`.
|
||||||
|
2. **Streaming cache budget.** Android runs on the fixed 4 GiB default
|
||||||
|
(`DiskCacheConfig::DEFAULT_BUDGET_BYTES`), large for a phone. Expose
|
||||||
|
`DiskCache::set_budget` through `pstr-android`, add a 1–16 GiB choice with a
|
||||||
|
usage readout under Storage. Desktop: `a432a93`.
|
||||||
|
3. **Hardware-decoding switch.** `pstr_mpv.cpp` hard-codes
|
||||||
|
`hwdec=auto-copy-safe`; a device with green frames has no way out. A
|
||||||
|
Settings toggle, applied from the next file.
|
||||||
|
4. **Episode list in the player.** A bottom sheet (portrait) or side panel
|
||||||
|
(landscape) with the title's episodes, opened on the playing season and
|
||||||
|
scrolled to the playing episode. Desktop: `7e734db`.
|
||||||
|
5. **Long-press menu on tiles.** Play/resume, mark watched or unwatched,
|
||||||
|
download, change match, remove from Continue watching, in a
|
||||||
|
`ModalBottomSheet`. Desktop: `c5808a5`.
|
||||||
|
6. **Undo on snackbars** for marking a title and leaving Continue watching.
|
||||||
|
`AppUiState.message` is a bare string today; it needs an action. Desktop:
|
||||||
|
`79c3f68`.
|
||||||
|
7. **History tab.** Needs a bridge call over the `pstr-core::library` query
|
||||||
|
the desktop page uses. Desktop: `e7eeaa5`.
|
||||||
|
8. **Buffered range on the seek bar** from mpv's `demuxer-cache-time`.
|
||||||
|
9. **Up-next card** with the next episode's still and a countdown ring instead
|
||||||
|
of "Up next in 7s" text.
|
||||||
|
10. **Featured banner** at the top of the library. Desktop: `7d03736`.
|
||||||
|
11. **Bundled Inter** in `ui/theme/Type.kt`, so both clients read the same.
|
||||||
|
Desktop: `558446b`.
|
||||||
|
|
||||||
|
## 2. UI recommendations
|
||||||
|
|
||||||
|
### Everywhere
|
||||||
|
|
||||||
|
- **Colours stay the theme's.** The red is the Persona theme's accent and the
|
||||||
|
card tint comes from the shared palette, so the redesign changes layout and
|
||||||
|
button hierarchy, not colours.
|
||||||
|
- **One button hierarchy.** At most one filled accent button per screen;
|
||||||
|
secondary actions tonal or outlined; destructive actions behind a menu and a
|
||||||
|
confirmation, never a filled red button in a list row.
|
||||||
|
- **Edge-to-edge, per-screen titles.** The "proton-stream" app bar on every
|
||||||
|
page wastes a band of height. Library uses a collapsing large title; Title
|
||||||
|
pages draw the backdrop under the status bar; other tabs use their name.
|
||||||
|
- **Skeletons instead of spinners** for library and title loading, and
|
||||||
|
generated placeholder art (gradient from the accent plus the title's
|
||||||
|
initials) instead of a black card with a play triangle (Akira).
|
||||||
|
- **Pull-to-refresh** (`PullToRefreshBox`) replaces the top-bar refresh icon,
|
||||||
|
with crawl progress shown in the indicator area.
|
||||||
|
- **Split `ui/ProtonStreamApp.kt`** (1665 lines) into one file per screen
|
||||||
|
before the work below; every item touches it.
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
- Poster grid (2:3, `posterUrl`) at three columns on a phone, title under the
|
||||||
|
poster with no card body. The current 210 dp cropped backdrops fit two titles
|
||||||
|
per screen.
|
||||||
|
- Continue watching: 16:9 still with the progress bar laid over the bottom
|
||||||
|
edge of the image and "S03E11 · 12 min left" under it.
|
||||||
|
- Search becomes an icon in the app bar that expands into an M3 `SearchBar`,
|
||||||
|
instead of a permanent field above everything.
|
||||||
|
- Fix "1 episodes" (line ~507; the season header already pluralises).
|
||||||
|
|
||||||
|
### Title page
|
||||||
|
|
||||||
|
- Full-bleed backdrop with a gradient scrim, poster, title and meta chips
|
||||||
|
(year, score, genres) over it, collapsing as the page scrolls.
|
||||||
|
- Synopsis clamped to three lines with "More".
|
||||||
|
- One full-width primary button, "Resume S03E11" or "Play S01E01", then a row
|
||||||
|
of icon actions: download, mark watched, and a ⋮ menu holding Start over,
|
||||||
|
Change match and More on AniList. Today five buttons of three styles wrap
|
||||||
|
over three lines.
|
||||||
|
- Drop the "Preferred tracks" form and its Save button from this page. The
|
||||||
|
player already remembers tracks by name per title (`d3c5f07`); expose any
|
||||||
|
override from the player's track chooser.
|
||||||
|
- Season picker as a dropdown chip or tabs, not stacked accordions; a 40
|
||||||
|
episode title is one long scroll today.
|
||||||
|
- Episode rows: 16:9 still with progress overlay, "1 · Mother and Children"
|
||||||
|
(provider name, else "Episode 1") rather than the raw file name
|
||||||
|
`[Judas] Oshi no Ko - S01E01.mkv`, runtime or air date as supporting text,
|
||||||
|
watched and download state as small trailing icons. The red `S01E01` label
|
||||||
|
above each row goes.
|
||||||
|
|
||||||
|
### Shares
|
||||||
|
|
||||||
|
- `ListItem` rows: name, "12 titles · crawled 2 h ago" or the failure, and a ⋮
|
||||||
|
menu with Refresh, Re-enter link and Remove (confirmed). The current row
|
||||||
|
wraps "Custom password stored securely" over three lines beside three
|
||||||
|
differently styled actions.
|
||||||
|
- "Add share" as an extended FAB, which is also where the share-link intent
|
||||||
|
from parity item 1 lands.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- Grouped `ListItem` rows (headline, one line of supporting text, trailing
|
||||||
|
switch), with the long explanations moved into the supporting line or an
|
||||||
|
info dialog. Today each toggle carries a paragraph.
|
||||||
|
- Split into sub-pages once storage and decoding settings land: Playback,
|
||||||
|
Downloads & storage, Appearance, Metadata, About.
|
||||||
|
- Language fields become a picker dialog over a language list, not free text.
|
||||||
|
- Storage shows a usage bar (cache, offline episodes) above the budget choice.
|
||||||
|
|
||||||
|
### Robustness
|
||||||
|
|
||||||
|
- Screenshot tests (Roborazzi, JVM, no device) for each screen in light and
|
||||||
|
dark, phone and tablet, at font scale 1.0 and 1.5 — the redesign changes
|
||||||
|
every screen, and `docs/TESTING.md` has no visual layer today.
|
||||||
|
- 48 dp minimum touch targets and content descriptions on the new icon-only
|
||||||
|
actions.
|
||||||
|
- Tablet: `ListDetailPaneScaffold` for library + title, since
|
||||||
|
`NavigationSuiteScaffold` already switches to a rail there.
|
||||||
|
- Shared-element transition from a poster to its title page, and predictive
|
||||||
|
back.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. CI green (section 0).
|
||||||
|
2. Split `ProtonStreamApp.kt`; add screenshot tests for the current screens.
|
||||||
|
3. Parity 1–3 (small, phone-specific fixes).
|
||||||
|
4. Button hierarchy and Inter — every later screen inherits them.
|
||||||
|
5. Title page, then Library, then Shares and Settings.
|
||||||
|
6. Parity 4–10.
|
||||||
@@ -72,7 +72,7 @@ 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. |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ distributions.
|
|||||||
| 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 |
|
||||||
| UniFFI | MPL-2.0 |
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
| proton-stream shared Rust crates | MIT |
|
| proton-stream shared Rust crates | MIT |
|
||||||
|
|
||||||
This summary is not a replacement for dependency license files. Before a
|
This summary is not a replacement for dependency license files. Before a
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.ktlint)
|
alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.roborazzi)
|
||||||
}
|
}
|
||||||
|
|
||||||
val repositoryRoot = rootProject.projectDir.parentFile
|
val repositoryRoot = rootProject.projectDir.parentFile
|
||||||
@@ -142,6 +143,9 @@ android {
|
|||||||
"GradleDependency",
|
"GradleDependency",
|
||||||
"NewerVersionAvailable",
|
"NewerVersionAvailable",
|
||||||
"AndroidGradlePluginVersion",
|
"AndroidGradlePluginVersion",
|
||||||
|
// The same for the platform: raising targetSdk opts into new
|
||||||
|
// behaviour and needs its own pass on a device.
|
||||||
|
"OldTargetApi",
|
||||||
)
|
)
|
||||||
sarifReport = true
|
sarifReport = true
|
||||||
}
|
}
|
||||||
@@ -176,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) {
|
||||||
@@ -269,6 +269,11 @@ dependencies {
|
|||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.androidx.test.core)
|
testImplementation(libs.androidx.test.core)
|
||||||
testImplementation(libs.androidx.work.testing)
|
testImplementation(libs.androidx.work.testing)
|
||||||
|
testImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.roborazzi)
|
||||||
|
testImplementation(libs.roborazzi.compose)
|
||||||
|
testImplementation(libs.roborazzi.junit.rule)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.junit)
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
@@ -292,6 +297,13 @@ ktlint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot baselines live beside the tests that draw them, so a change to how
|
||||||
|
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
|
||||||
|
// rewrites them; `verifyRoborazziDebug` fails on any difference.
|
||||||
|
roborazzi {
|
||||||
|
outputDir.set(file("src/test/screenshots"))
|
||||||
|
}
|
||||||
|
|
||||||
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||||
// though the filter above drops every file in it. Without this, Gradle refuses
|
// though the filter above drops every file in it. Without this, Gradle refuses
|
||||||
// the build for an undeclared dependency between the two tasks.
|
// the build for an undeclared dependency between the two tasks.
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
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:icon="@drawable/ic_launcher_foreground"
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
tools:targetApi="33"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
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">
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/launcher_name"
|
||||||
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"
|
||||||
@@ -38,6 +41,24 @@
|
|||||||
<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" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!--
|
||||||
|
Proton Drive public links. autoVerify="false", said explicitly:
|
||||||
|
the domain is Proton's, so this can never be a verified App Link.
|
||||||
|
Android only opens these here once "Open supported links" is
|
||||||
|
allowed in the app's settings; sharing a link to the app works
|
||||||
|
without that.
|
||||||
|
-->
|
||||||
|
<intent-filter android:autoVerify="false">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="drive.proton.me" android:pathPrefix="/urls/" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -11,6 +11,7 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
|||||||
| 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 |
|
||||||
| UniFFI | MPL-2.0 |
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
| proton-stream reusable Rust crates | MIT |
|
| proton-stream reusable Rust crates | MIT |
|
||||||
|
|
||||||
Before release, replace this dependency summary with an inventory generated
|
Before release, replace this dependency summary with an inventory generated
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ extern "C" {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char *kProtocol = "pstr";
|
constexpr const char *kProtocol = "pstr";
|
||||||
|
/// mpv's `hwdec` when hardware decoding is on. Why this value: `Player()`.
|
||||||
|
constexpr const char *kHardwareDecoding = "auto-copy-safe";
|
||||||
|
|
||||||
struct StreamCookie {
|
struct StreamCookie {
|
||||||
uint64_t handle;
|
uint64_t handle;
|
||||||
@@ -191,6 +193,9 @@ struct PlaybackState {
|
|||||||
/// Between a seek being issued and playback actually resuming. The seek bar
|
/// Between a seek being issued and playback actually resuming. The seek bar
|
||||||
/// has moved but the picture has not.
|
/// has moved but the picture has not.
|
||||||
bool seeking = false;
|
bool seeking = false;
|
||||||
|
/// How far into the file the demuxer has read ahead, in seconds; what the
|
||||||
|
/// seek bar shades, so a jump into it is known to cost no fetch.
|
||||||
|
double cached_until = 0.0;
|
||||||
/// True between `loadfile` being issued and mpv acknowledging it with
|
/// True between `loadfile` being issued and mpv acknowledging it with
|
||||||
/// `START_FILE`. Everything mpv reports in that window still describes the
|
/// `START_FILE`. Everything mpv reports in that window still describes the
|
||||||
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
|
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
|
||||||
@@ -216,6 +221,7 @@ class Player {
|
|||||||
option("vo", "libmpv");
|
option("vo", "libmpv");
|
||||||
option("force-window", "no");
|
option("force-window", "no");
|
||||||
option("video-timing-offset", "0");
|
option("video-timing-offset", "0");
|
||||||
|
// The default; `load` sets it per file from the viewer's setting.
|
||||||
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
||||||
// an `ANativeWindow` the decoder is handed at init, and this player has
|
// an `ANativeWindow` the decoder is handed at init, and this player has
|
||||||
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
|
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
|
||||||
@@ -224,7 +230,7 @@ class Player {
|
|||||||
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
||||||
// falling back. The copy variants decode in hardware and hand back
|
// falling back. The copy variants decode in hardware and hand back
|
||||||
// ordinary frames, which is what this render path can actually use.
|
// ordinary frames, which is what this render path can actually use.
|
||||||
option("hwdec", "auto-copy-safe");
|
option("hwdec", kHardwareDecoding);
|
||||||
option("cache", "yes");
|
option("cache", "yes");
|
||||||
option("cache-secs", "30");
|
option("cache-secs", "30");
|
||||||
option("demuxer-readahead-secs", "30");
|
option("demuxer-readahead-secs", "30");
|
||||||
@@ -245,6 +251,7 @@ class Player {
|
|||||||
observe("video-params/dh", 7, MPV_FORMAT_INT64);
|
observe("video-params/dh", 7, MPV_FORMAT_INT64);
|
||||||
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
|
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
|
||||||
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
|
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
|
||||||
|
observe("demuxer-cache-time", 10, MPV_FORMAT_DOUBLE);
|
||||||
// Errors mpv reports about the file itself, rather than about the
|
// Errors mpv reports about the file itself, rather than about the
|
||||||
// request that opened it. Without these a failed demux is silent.
|
// request that opened it. Without these a failed demux is silent.
|
||||||
mpv_request_log_messages(mpv_, "error");
|
mpv_request_log_messages(mpv_, "error");
|
||||||
@@ -294,8 +301,12 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool load(uint64_t handle, double start, const std::string &audio,
|
bool load(uint64_t handle, double start, const std::string &audio,
|
||||||
const std::string &subtitle, bool subtitles) {
|
const std::string &subtitle, bool subtitles,
|
||||||
|
bool hardware_decoding) {
|
||||||
if (!mpv_) return false;
|
if (!mpv_) return false;
|
||||||
|
// Per file, before `loadfile`: a device whose decoder shows green
|
||||||
|
// frames is fixed from the next episode without restarting the core.
|
||||||
|
set_string("hwdec", hardware_decoding ? kHardwareDecoding : "no");
|
||||||
// No wait for a render context. It is created with the render thread and
|
// No wait for a render context. It is created with the render thread and
|
||||||
// needs no window, and a load that cannot get one plays audio anyway —
|
// needs no window, and a load that cannot get one plays audio anyway —
|
||||||
// waiting for a surface here is what made background playback fail after
|
// waiting for a surface here is what made background playback fail after
|
||||||
@@ -591,6 +602,7 @@ class Player {
|
|||||||
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
|
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
|
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
|
||||||
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
|
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
|
else if (!std::strcmp(property.name, "demuxer-cache-time")) state_.cached_until = *static_cast<double *>(property.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void render_update(void *opaque) noexcept {
|
static void render_update(void *opaque) noexcept {
|
||||||
@@ -822,7 +834,7 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
|
|||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles) noexcept {
|
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles, jboolean hardware_decoding) noexcept {
|
||||||
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
||||||
std::string audio_text;
|
std::string audio_text;
|
||||||
std::string subtitle_text;
|
std::string subtitle_text;
|
||||||
@@ -832,7 +844,8 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
|||||||
auto *p = from(handle);
|
auto *p = from(handle);
|
||||||
return static_cast<jboolean>(
|
return static_cast<jboolean>(
|
||||||
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
||||||
subtitle_text, subtitles == JNI_TRUE)
|
subtitle_text, subtitles == JNI_TRUE,
|
||||||
|
hardware_decoding == JNI_TRUE)
|
||||||
? JNI_TRUE
|
? JNI_TRUE
|
||||||
: JNI_FALSE);
|
: JNI_FALSE);
|
||||||
});
|
});
|
||||||
@@ -868,8 +881,8 @@ 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.seeking ? 1.0 : 0.0, state.cached_until};
|
||||||
constexpr jsize kFields = 13;
|
constexpr jsize kFields = 14;
|
||||||
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);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Proton Drive public link, wherever in a piece of text it sits.
|
||||||
|
*
|
||||||
|
* The fragment is kept: it is the share's secret, and a link without it cannot
|
||||||
|
* be opened. Matching stops at whitespace and at the quotes and brackets a
|
||||||
|
* message app wraps a link in, which never occur in a link itself.
|
||||||
|
*/
|
||||||
|
private val SHARE_LINK = Regex("""https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+""")
|
||||||
|
|
||||||
|
/** The share link an `ACTION_VIEW` URL or an `ACTION_SEND` text carries, if any. */
|
||||||
|
internal fun shareLinkFrom(action: String?, url: String?, text: CharSequence?): String? = when (action) {
|
||||||
|
Intent.ACTION_VIEW -> url?.let { SHARE_LINK.matchEntire(it)?.value }
|
||||||
|
Intent.ACTION_SEND -> text?.let { SHARE_LINK.find(it)?.value }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Intent.shareLink(): String? =
|
||||||
|
shareLinkFrom(action, dataString, getCharSequenceExtra(Intent.EXTRA_TEXT))
|
||||||
@@ -48,6 +48,9 @@ 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)
|
private val inPictureInPicture = mutableStateOf(false)
|
||||||
|
|
||||||
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||||
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||||
/**
|
/**
|
||||||
* Whether a bind is outstanding, which is what has to be unbound.
|
* Whether a bind is outstanding, which is what has to be unbound.
|
||||||
*
|
*
|
||||||
@@ -97,6 +100,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Infallible by construction — an unreadable theme file resolves to the
|
// Infallible by construction — an unreadable theme file resolves to the
|
||||||
// shipped default rather than throwing.
|
// shipped default rather than throwing.
|
||||||
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
||||||
|
// Only on a fresh start: a recreated activity is handed the intent it
|
||||||
|
// was launched with again, and would offer to add the same share twice.
|
||||||
|
if (savedInstanceState == null) incomingShareLink.value = intent.shareLink()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
// Re-applied on every change, not just at startup: picking Latte from
|
// Re-applied on every change, not just at startup: picking Latte from
|
||||||
// the settings page has to turn the status bar's icons dark in the same
|
// the settings page has to turn the status bar's icons dark in the same
|
||||||
@@ -134,7 +140,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
ProtonStreamApp(
|
||||||
|
playerHost.value,
|
||||||
|
inPictureInPicture.value,
|
||||||
|
shareLink = incomingShareLink.value,
|
||||||
|
onShareLinkTaken = { incomingShareLink.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +170,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// singleTask: a link opened while the app is running arrives here, not in
|
||||||
|
// a second activity.
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
override fun onUserLeaveHint() {
|
||||||
super.onUserLeaveHint()
|
super.onUserLeaveHint()
|
||||||
// The backstop for launchers that still send this instead of arming
|
// The backstop for launchers that still send this instead of arming
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.narl.protonstream.native
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -56,5 +57,9 @@ object NativeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createEngine(): AndroidEngine =
|
private fun createEngine(): AndroidEngine =
|
||||||
AndroidEngine(paths(), KeystoreSecretStore(appContext))
|
AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply {
|
||||||
|
// The budget is Android's setting, so the engine starts on the
|
||||||
|
// default and is told the chosen one before anything streams.
|
||||||
|
setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,8 @@ data class MpvPlaybackState(
|
|||||||
val cachePercent: Double = 0.0,
|
val cachePercent: Double = 0.0,
|
||||||
/** A seek has been issued but playback has not resumed at the new position. */
|
/** A seek has been issued but playback has not resumed at the new position. */
|
||||||
val seeking: Boolean = false,
|
val seeking: Boolean = false,
|
||||||
|
/** How far the demuxer has read ahead, in seconds into the file. */
|
||||||
|
val cachedUntil: Double = 0.0,
|
||||||
) {
|
) {
|
||||||
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
||||||
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
||||||
@@ -184,12 +186,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
audioLanguage: String?,
|
audioLanguage: String?,
|
||||||
subtitleLanguage: String?,
|
subtitleLanguage: String?,
|
||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
|
hardwareDecoding: Boolean,
|
||||||
) {
|
) {
|
||||||
check(size > 0uL) { "Cannot play an empty stream" }
|
check(size > 0uL) { "Cannot play an empty stream" }
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val loaded = withOpenHandle {
|
val loaded = withOpenHandle {
|
||||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
nativeLoad(
|
||||||
|
it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles,
|
||||||
|
hardwareDecoding,
|
||||||
|
).also { loaded ->
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
// The outgoing stream is released by mpv closing it,
|
// The outgoing stream is released by mpv closing it,
|
||||||
// not from here: `loadfile` is asynchronous and its
|
// not from here: `loadfile` is asynchronous and its
|
||||||
@@ -309,6 +315,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
buffering = values[BUFFERING] != 0.0,
|
buffering = values[BUFFERING] != 0.0,
|
||||||
cachePercent = values[CACHE_PERCENT],
|
cachePercent = values[CACHE_PERCENT],
|
||||||
seeking = values[SEEKING] != 0.0,
|
seeking = values[SEEKING] != 0.0,
|
||||||
|
cachedUntil = values[CACHED_UNTIL],
|
||||||
)
|
)
|
||||||
mutableState.value = next
|
mutableState.value = next
|
||||||
// The listeners drive the media session and the player UI,
|
// The listeners drive the media session and the player UI,
|
||||||
@@ -398,7 +405,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||||
private external fun nativeDetachSurface(handle: Long)
|
private external fun nativeDetachSurface(handle: Long)
|
||||||
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
|
private external fun nativeLoad(
|
||||||
|
handle: Long,
|
||||||
|
stream: Long,
|
||||||
|
start: Double,
|
||||||
|
audio: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
subtitles: Boolean,
|
||||||
|
hardwareDecoding: Boolean,
|
||||||
|
): 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)
|
||||||
@@ -419,7 +434,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 = 13
|
private const val FIELDS = 14
|
||||||
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
|
||||||
@@ -427,6 +442,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private const val BUFFERING = 10
|
private const val BUFFERING = 10
|
||||||
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
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
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.AccentTrack
|
import io.narl.protonstream.ui.theme.AccentTrack
|
||||||
@@ -83,6 +84,19 @@ 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.thumbnailSource
|
import io.narl.protonstream.ui.thumbnailSource
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material.icons.filled.VideoLibrary
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import io.narl.protonstream.ui.RemoteArtwork
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -207,6 +221,7 @@ fun PlayerScreen(
|
|||||||
nativeHost.play(
|
nativeHost.play(
|
||||||
key, startup.nativeHandle, startup.size, startup.startPosition,
|
key, startup.nativeHandle, startup.size, startup.startPosition,
|
||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
|
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)
|
||||||
@@ -272,6 +287,12 @@ fun PlayerScreen(
|
|||||||
nativeHost.beginTransition()
|
nativeHost.beginTransition()
|
||||||
onIndexChange(index - 1)
|
onIndexChange(index - 1)
|
||||||
}
|
}
|
||||||
|
fun jumpTo(target: Int) {
|
||||||
|
if (target == index || target !in episodes.indices) return
|
||||||
|
save()
|
||||||
|
nativeHost.beginTransition()
|
||||||
|
onIndexChange(target)
|
||||||
|
}
|
||||||
|
|
||||||
// What the media notification, the lock screen and Picture-in-Picture show.
|
// What the media notification, the lock screen and Picture-in-Picture show.
|
||||||
// None of them can reach this composition, and all three have to name the
|
// None of them can reach this composition, and all three have to name the
|
||||||
@@ -281,7 +302,7 @@ fun PlayerScreen(
|
|||||||
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,
|
||||||
@@ -404,7 +425,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,
|
||||||
@@ -444,6 +465,9 @@ fun PlayerScreen(
|
|||||||
hasNext = next != null,
|
hasNext = next != null,
|
||||||
onPrevious = { retreat() },
|
onPrevious = { retreat() },
|
||||||
onNext = { advance() },
|
onNext = { advance() },
|
||||||
|
episodes = episodes,
|
||||||
|
playing = index,
|
||||||
|
onEpisode = { jumpTo(it) },
|
||||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
||||||
landscapeLocked = landscapeLocked,
|
landscapeLocked = landscapeLocked,
|
||||||
)
|
)
|
||||||
@@ -591,11 +615,15 @@ private fun PlayerControls(
|
|||||||
hasNext: Boolean,
|
hasNext: Boolean,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
playing: Int,
|
||||||
|
onEpisode: (Int) -> Unit,
|
||||||
onRotate: () -> Unit,
|
onRotate: () -> Unit,
|
||||||
landscapeLocked: Boolean,
|
landscapeLocked: Boolean,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var choosing by remember { mutableStateOf<String?>(null) }
|
var choosing by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showEpisodes by remember { mutableStateOf(false) }
|
||||||
var showChapters by remember { mutableStateOf(false) }
|
var showChapters by remember { mutableStateOf(false) }
|
||||||
var showSpeeds by remember { mutableStateOf(false) }
|
var showSpeeds by remember { mutableStateOf(false) }
|
||||||
// What the store says, so the dialog opens on the rate that is playing.
|
// What the store says, so the dialog opens on the rate that is playing.
|
||||||
@@ -638,7 +666,12 @@ private fun PlayerControls(
|
|||||||
scrubbing = null
|
scrubbing = null
|
||||||
},
|
},
|
||||||
valueRange = 0f..duration,
|
valueRange = 0f..duration,
|
||||||
track = { AccentTrack(it) },
|
track = {
|
||||||
|
AccentTrack(
|
||||||
|
it,
|
||||||
|
buffered = if (state.duration > 0) (state.cachedUntil / state.duration).toFloat() else 0f,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
|
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
|
||||||
}
|
}
|
||||||
@@ -664,6 +697,9 @@ 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) {
|
||||||
|
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||||
|
}
|
||||||
if (chapters.size > 1) {
|
if (chapters.size > 1) {
|
||||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||||
}
|
}
|
||||||
@@ -716,6 +752,14 @@ private fun PlayerControls(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showEpisodes) {
|
||||||
|
EpisodeChooser(
|
||||||
|
episodes = episodes,
|
||||||
|
playing = playing,
|
||||||
|
onDismiss = { showEpisodes = false },
|
||||||
|
onSelect = { showEpisodes = false; onEpisode(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showChapters) {
|
if (showChapters) {
|
||||||
ChapterChooser(
|
ChapterChooser(
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
@@ -870,6 +914,83 @@ private fun ChapterChooser(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title's episodes, from the player: a sheet rather than a dialog, opened
|
||||||
|
* scrolled to the one playing so the next few are in view without a search.
|
||||||
|
* Each row has its still, its name, and whether it is playing, watched or
|
||||||
|
* part way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeChooser(
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
playing: Int,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val list = rememberLazyListState(initialFirstVisibleItemIndex = (playing - 1).coerceAtLeast(0))
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Text(
|
||||||
|
"Episodes",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(state = list, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
|
itemsIndexed(episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||||
|
val current = position == playing
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(position) }
|
||||||
|
.background(
|
||||||
|
if (current) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(Modifier.width(112.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl,
|
||||||
|
episode.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
episode.providerName ?: episode.label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
current -> "Playing"
|
||||||
|
episode.watched -> "${episode.label} · Watched"
|
||||||
|
episode.resumeAt != null -> "${episode.label} · Part way"
|
||||||
|
else -> episode.label
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (current) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The countdown into the next episode, shown once the credits run starts.
|
* The countdown into the next episode, shown once the credits run starts.
|
||||||
*
|
*
|
||||||
@@ -898,26 +1019,65 @@ private fun UpNextCard(
|
|||||||
}
|
}
|
||||||
// `showing` already implies a non-null episode, and the compiler knows it.
|
// `showing` already implies a non-null episode, and the compiler knows it.
|
||||||
if (!showing) return
|
if (!showing) return
|
||||||
|
// Eased between ticks, so the ring runs down rather than stepping once a
|
||||||
|
// second.
|
||||||
|
val ring by animateFloatAsState(
|
||||||
|
targetValue = remaining.toFloat() / UP_NEXT_SECONDS,
|
||||||
|
animationSpec = tween(durationMillis = 1_000, easing = LinearEasing),
|
||||||
|
label = "up-next countdown",
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
.width(280.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
"Up next in ${remaining}s",
|
// The next episode's still, so the card says which episode
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
// without being read; the ring over it is both the countdown and
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// the button that skips it.
|
||||||
)
|
Box(
|
||||||
Text(
|
Modifier
|
||||||
upNext.label,
|
.width(112.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
.aspectRatio(16f / 9f)
|
||||||
maxLines = 1,
|
.clip(MaterialTheme.shapes.small)
|
||||||
overflow = TextOverflow.Ellipsis,
|
.clickable(onClick = onPlayNow),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
contentAlignment = Alignment.Center,
|
||||||
)
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
|
RemoteArtwork(
|
||||||
AccentButton(onClick = onPlayNow) { Text("Play now") }
|
upNext.stillUrl,
|
||||||
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
|
upNext.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = upNext.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { ring },
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
color = Color.White,
|
||||||
|
trackColor = Color.White.copy(alpha = 0.25f),
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
)
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Play now", tint = Color.White)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
"Up next in ${remaining}s",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
upNext.providerName ?: upNext.label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text("Watch till the end")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface LibmpvHost {
|
|||||||
audioLanguage: String? = null,
|
audioLanguage: String? = null,
|
||||||
subtitleLanguage: String? = null,
|
subtitleLanguage: String? = null,
|
||||||
subtitles: Boolean = true,
|
subtitles: Boolean = true,
|
||||||
|
hardwareDecoding: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||||
|
|||||||
@@ -20,9 +20,34 @@ class SettingsStore(context: Context) {
|
|||||||
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
||||||
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
||||||
|
|
||||||
private companion object {
|
/**
|
||||||
const val NAME = "proton_stream_settings"
|
* Whether mpv may decode in hardware. Off is the way out for a device whose
|
||||||
const val KEY_WIFI_ONLY = "wifi_only"
|
* MediaCodec decoder shows green or torn frames; it costs battery, so it is
|
||||||
const val KEY_BACKGROUND_AUDIO = "background_audio"
|
* on unless the viewer turns it off. Read at each load, so a change applies
|
||||||
|
* from the next file.
|
||||||
|
*/
|
||||||
|
var hardwareDecoding: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_HARDWARE_DECODING, true)
|
||||||
|
set(value) { preferences.edit().putBoolean(KEY_HARDWARE_DECODING, value).apply() }
|
||||||
|
|
||||||
|
/** The streaming cache's budget, in GiB. See [CACHE_BUDGET_CHOICES]. */
|
||||||
|
var cacheBudgetGib: Int
|
||||||
|
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||||
|
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||||
|
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||||
|
|
||||||
|
/** `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing. */
|
||||||
|
const val DEFAULT_CACHE_BUDGET_GIB = 4
|
||||||
|
|
||||||
|
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||||
|
|
||||||
|
private const val NAME = "proton_stream_settings"
|
||||||
|
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||||
|
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||||
|
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||||
|
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,8 @@ 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 uniffi.pstr_android.ArrangementRecord
|
||||||
|
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
|
||||||
@@ -33,12 +35,21 @@ import uniffi.pstr_android.MetadataProvider
|
|||||||
import uniffi.pstr_android.MetadataSettingsRecord
|
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
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: 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 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(
|
||||||
@@ -49,8 +60,16 @@ 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 Undo on [message] puts back, when the message offers one. */
|
||||||
|
val undo: List<WatchSnapshot>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One episode's watch state as it was before a change the viewer can undo.
|
||||||
|
* Null [state] means it had never been played.
|
||||||
|
*/
|
||||||
|
data class WatchSnapshot(val shareId: String, val linkId: String, val state: WatchStateRecord?)
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
@@ -77,6 +96,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reorder the grid, or fold or unfold its franchises. */
|
||||||
|
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||||
|
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||||
|
viewModelScope.launch { 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
|
||||||
@@ -185,7 +210,101 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissMessage() = mutableState.update { it.copy(message = null) }
|
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||||
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
|
* menu should not cost them.
|
||||||
|
*/
|
||||||
|
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||||
|
val episodes = title.seasons.flatMap { it.episodes }
|
||||||
|
changeWatch(
|
||||||
|
episodes,
|
||||||
|
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
|
||||||
|
) { engine, episode, before ->
|
||||||
|
val duration = before?.durationSecs
|
||||||
|
engine.saveWatchState(
|
||||||
|
episode.shareId,
|
||||||
|
episode.linkId,
|
||||||
|
if (watched) duration ?: 0.0 else 0.0,
|
||||||
|
duration,
|
||||||
|
watched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off Continue watching by forgetting where the episode stopped — what the
|
||||||
|
* desktop does — with an Undo that puts the position back.
|
||||||
|
*/
|
||||||
|
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
|
||||||
|
changeWatch(
|
||||||
|
listOf(episode),
|
||||||
|
"${title.displayName} removed from Continue watching",
|
||||||
|
) { engine, target, before ->
|
||||||
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off the history page: the position and the watched mark both go, which
|
||||||
|
* is "never played", with an Undo.
|
||||||
|
*/
|
||||||
|
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
|
||||||
|
changeWatch(
|
||||||
|
listOf(episode),
|
||||||
|
"${episode.label} of ${title.displayName} removed from history",
|
||||||
|
) { engine, target, before ->
|
||||||
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put back what the last undoable change replaced. */
|
||||||
|
fun undo() {
|
||||||
|
val snapshots = mutableState.value.undo ?: return
|
||||||
|
mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
snapshots.forEach { snapshot ->
|
||||||
|
val state = snapshot.state
|
||||||
|
engine.saveWatchState(
|
||||||
|
snapshot.shareId,
|
||||||
|
snapshot.linkId,
|
||||||
|
state?.positionSecs ?: 0.0,
|
||||||
|
state?.durationSecs,
|
||||||
|
state?.watched ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeWatch(
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
message: String,
|
||||||
|
change: (uniffi.pstr_android.AndroidEngine, EpisodeRecord, WatchStateRecord?) -> Unit,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
episodes.map { episode ->
|
||||||
|
val before = engine.watchState(episode.shareId, episode.linkId)
|
||||||
|
change(engine, episode, before)
|
||||||
|
WatchSnapshot(episode.shareId, episode.linkId, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess { snapshots ->
|
||||||
|
mutableState.update { it.copy(message = message, undo = snapshots) }
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun reportError(error: Throwable) {
|
fun reportError(error: Throwable) {
|
||||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
@@ -341,9 +460,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
// and cached: a full reload is the one place that has
|
// and cached: a full reload is the one place that has
|
||||||
// already paid for a walk of the offline files.
|
// already paid for a walk of the offline files.
|
||||||
engine.pruneOfflineFiles()
|
engine.pruneOfflineFiles()
|
||||||
|
val current = mutableState.value
|
||||||
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
Reloaded(
|
Reloaded(
|
||||||
engine.shares(),
|
engine.shares(),
|
||||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
engine.library(query),
|
||||||
|
engine.arrangement(query, current.sort, current.grouped),
|
||||||
engine.offlineFiles(),
|
engine.offlineFiles(),
|
||||||
engine.metadataSettings(),
|
engine.metadataSettings(),
|
||||||
engine.storageUsage(),
|
engine.storageUsage(),
|
||||||
@@ -354,6 +476,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
loading = false,
|
loading = false,
|
||||||
shares = result.shares,
|
shares = result.shares,
|
||||||
titles = result.titles,
|
titles = result.titles,
|
||||||
|
arrangement = result.arrangement,
|
||||||
offline = result.offline,
|
offline = result.offline,
|
||||||
metadataSettings = result.metadataSettings,
|
metadataSettings = result.metadataSettings,
|
||||||
storage = result.storage,
|
storage = result.storage,
|
||||||
@@ -366,10 +489,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.sort, current.grouped)
|
||||||
|
}
|
||||||
|
}.onSuccess { (titles, arrangement) ->
|
||||||
|
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||||
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||||
@@ -381,6 +510,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,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
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
|
||||||
|
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) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Play on a title lands: the most recently played part-watched episode,
|
||||||
|
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||||
|
*/
|
||||||
|
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||||
|
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||||
|
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
|
||||||
|
internal fun formatBytes(bytes: ULong): String {
|
||||||
|
val value = bytes.toDouble()
|
||||||
|
return when {
|
||||||
|
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
|
||||||
|
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
|
||||||
|
value >= 1024 -> "%.1f KiB".format(value / 1024)
|
||||||
|
else -> "$bytes bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
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.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.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
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
|
||||||
|
internal fun DownloadsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onRemove: (OfflineRecord) -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||||
|
val downloads by work.observeAsState(emptyList())
|
||||||
|
// Reading retained metadata on every WorkInfo transition also hydrates
|
||||||
|
// paused/failed/cancelled entries after WorkManager history is pruned.
|
||||||
|
val retained = remember(downloads) { DownloadStateStore(context).records() }
|
||||||
|
// Which show each saved file is from. The offline record knows its episode
|
||||||
|
// but not its show, and the library is the only thing that does.
|
||||||
|
val groups = remember(state.offline, state.titles) {
|
||||||
|
val shows = state.titles.flatMap { title ->
|
||||||
|
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||||
|
}.toMap()
|
||||||
|
state.offline
|
||||||
|
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||||
|
.map { (title, files) ->
|
||||||
|
OfflineGroup(title?.displayName ?: "Not in the library", title, files)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||||
|
// keep it, and getting it back is a download, not a tap.
|
||||||
|
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
|
||||||
|
TabPage("Downloads", padding) { body ->
|
||||||
|
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||||
|
}
|
||||||
|
return@TabPage
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(body),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleting?.let { file ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleting = null },
|
||||||
|
title = { Text("Delete this download?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
|
||||||
|
"It still streams, and can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = {
|
||||||
|
deleting = null
|
||||||
|
onRemove(file)
|
||||||
|
}) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
||||||
|
val episode = file.episode
|
||||||
|
ListItem(
|
||||||
|
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 = {
|
||||||
|
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(
|
||||||
|
listOfNotNull(
|
||||||
|
download.status.replaceFirstChar { it.uppercase() },
|
||||||
|
download.total.takeIf { it > 0L }?.let {
|
||||||
|
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||||
|
},
|
||||||
|
).joinToString(" · "),
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
|
||||||
|
/** One played episode, with the title it belongs to and its place in that title's playlist. */
|
||||||
|
internal data class Played(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every episode that has been started or finished, newest first — the
|
||||||
|
* desktop's `Library::history`, over the records the library already holds.
|
||||||
|
*
|
||||||
|
* A row marked unwatched again sits at position zero, which is "never played"
|
||||||
|
* for every purpose here, so it is left out. Progress stands in for the
|
||||||
|
* position: it is non-zero exactly when the position is.
|
||||||
|
*/
|
||||||
|
internal fun history(titles: List<TitleRecord>): List<Played> =
|
||||||
|
titles.flatMap { title ->
|
||||||
|
title.seasons.flatMap(SeasonRecord::episodes).withIndex()
|
||||||
|
.filter { (_, episode) -> episode.watched || (episode.progress ?: 0.0) > 0.0 }
|
||||||
|
.map { (index, episode) -> Played(title, episode, index) }
|
||||||
|
}.sortedByDescending { it.episode.lastPlayed }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Today", "Yesterday", a weekday within the last week, then a date — how the
|
||||||
|
* history page heads each day.
|
||||||
|
*/
|
||||||
|
internal fun dayHeading(day: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when {
|
||||||
|
day == today -> "Today"
|
||||||
|
day == today.minusDays(1) -> "Yesterday"
|
||||||
|
day.isAfter(today.minusDays(7)) -> day.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
|
||||||
|
day.year == today.year -> day.format(DateTimeFormatter.ofPattern("d MMMM", locale))
|
||||||
|
else -> day.format(DateTimeFormatter.ofPattern("d MMMM yyyy", locale))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onPlay: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRemove: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
zone: ZoneId = remember { ZoneId.systemDefault() },
|
||||||
|
today: LocalDate = LocalDate.now(zone),
|
||||||
|
) {
|
||||||
|
val days = remember(state.titles) {
|
||||||
|
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||||
|
}
|
||||||
|
if (days.isEmpty()) {
|
||||||
|
TabPage("History", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TabPage("History", padding) { body ->
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
|
days.forEach { (day, played) ->
|
||||||
|
item(key = "day/$day") {
|
||||||
|
Text(
|
||||||
|
dayHeading(day, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
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(
|
||||||
|
entry,
|
||||||
|
onPlay = { onPlay(entry.title, entry.index) },
|
||||||
|
onTitle = { onTitle(entry.title) },
|
||||||
|
onRemove = { onRemove(entry.title, entry.episode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The still plays the episode, the text opens its title, and the cross takes
|
||||||
|
* it off the page — the same three targets the desktop row has.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, onRemove: () -> Unit) {
|
||||||
|
val episode = entry.episode
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(onClick = onPlay),
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
|
episode.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
entry.title.displayName,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
episode.providerName?.let { "${episode.label} · $it" } ?: episode.label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (episode.watched) "Finished" else episode.stoppedAt(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Remove from history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Stopped at 12:04 of 23:40", or as much of it as is known. */
|
||||||
|
private fun EpisodeRecord.stoppedAt(): String {
|
||||||
|
val at = resumeAt ?: return "Started"
|
||||||
|
val duration = progress?.takeIf { it > 0.0 }?.let { at / it }
|
||||||
|
return "Stopped at ${clock(at)}" + (duration?.let { " of ${clock(it)}" } ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clock(seconds: Double): String {
|
||||||
|
val total = seconds.toInt().coerceAtLeast(0)
|
||||||
|
val hours = total / 3_600
|
||||||
|
return if (hours > 0) "%d:%02d:%02d".format(hours, total % 3_600 / 60, total % 60)
|
||||||
|
else "%d:%02d".format(total / 60, total % 60)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
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.layout.Row
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import java.time.LocalDate
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
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.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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.LibrarySort
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many shows the continue-watching shelf holds.
|
||||||
|
*
|
||||||
|
* A shelf is a shortcut, not a second library: past about this many it is
|
||||||
|
* quicker to search for the thing than to scroll the shelf looking for it.
|
||||||
|
*/
|
||||||
|
private const val CONTINUE_WATCHING_MAX = 12
|
||||||
|
|
||||||
|
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||||
|
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
@Composable
|
||||||
|
internal fun LibraryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
||||||
|
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
onMatchChanged: () -> Unit = {},
|
||||||
|
onError: (Throwable) -> Unit = {},
|
||||||
|
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
|
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||||
|
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
// Most recently played first, one episode per show: a shelf that lists four
|
||||||
|
// episodes of the same series is a shelf with room for nothing else.
|
||||||
|
val resumable = remember(state.titles) {
|
||||||
|
state.titles.mapNotNull { title ->
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
playlist.withIndex()
|
||||||
|
.filter { it.value.resumeAt != null && !it.value.watched }
|
||||||
|
.maxByOrNull { it.value.lastPlayed }
|
||||||
|
?.let { Resumable(title, it.value, it.index) }
|
||||||
|
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
|
}
|
||||||
|
// Search is an icon until it is wanted: a permanent field above the
|
||||||
|
// library spent a band of every visit on something used in few of them.
|
||||||
|
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
|
||||||
|
val closeSearch = {
|
||||||
|
searching = false
|
||||||
|
onSearch("")
|
||||||
|
}
|
||||||
|
BackHandler(enabled = searching, onBack = closeSearch)
|
||||||
|
// The large title folds into the bar as the grid scrolls, and comes back
|
||||||
|
// only at the top: the page says what it is on arrival, then gets out of
|
||||||
|
// the way.
|
||||||
|
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
if (searching) {
|
||||||
|
SearchBar(state.query, onSearch, closeSearch)
|
||||||
|
} else {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text("Library") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { searching = true }) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||||
|
}
|
||||||
|
ArrangeMenu(state.sort, state.grouped, onArrange)
|
||||||
|
// Pull to refresh is the gesture; the icon stays for a
|
||||||
|
// viewer who does not know it.
|
||||||
|
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = state.refreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.weight(1f).padding(padding),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> LibrarySkeleton()
|
||||||
|
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
||||||
|
"Nothing matches",
|
||||||
|
"No title in the library is called “${state.query.trim()}”.",
|
||||||
|
)
|
||||||
|
state.titles.isEmpty() -> EmptyState(
|
||||||
|
"Your library is empty",
|
||||||
|
"Add a Proton Drive public link under Shares, then refresh.",
|
||||||
|
)
|
||||||
|
else -> LibraryGrid(
|
||||||
|
state,
|
||||||
|
resumable,
|
||||||
|
onResume,
|
||||||
|
onTitle,
|
||||||
|
onTitleMenu = { menuFor = it },
|
||||||
|
onResumeMenu = { menuResume = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menuFor?.let { title ->
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||||
|
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||||
|
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||||
|
onResume(title, nextUpIndex(playlist))
|
||||||
|
}
|
||||||
|
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||||
|
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||||
|
MenuRow(
|
||||||
|
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
|
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||||
|
) { 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 ->
|
||||||
|
TileMenu(
|
||||||
|
entry.title.displayName,
|
||||||
|
entry.episode.label,
|
||||||
|
onDismiss = { menuResume = null },
|
||||||
|
) {
|
||||||
|
MenuRow(Icons.Default.PlayArrow, "Resume") { onResume(entry.title, entry.index) }
|
||||||
|
MenuRow(Icons.Default.Info, "Open") { onTitle(entry.title) }
|
||||||
|
MenuRow(Icons.Default.RemoveCircleOutline, "Remove from Continue watching") {
|
||||||
|
onForgetPosition(entry.title, entry.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* is. Each row closes it before doing its thing.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TileMenu(
|
||||||
|
heading: String,
|
||||||
|
caption: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
rows: @Composable MenuScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.padding(bottom = 24.dp)) {
|
||||||
|
Text(
|
||||||
|
heading,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
caption,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
MenuScope(onDismiss).rows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuScope(val dismiss: () -> Unit)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MenuScope.MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
leadingContent = { Icon(icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { dismiss(); onClick() }.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryGrid(
|
||||||
|
state: AppUiState,
|
||||||
|
resumable: List<Resumable>,
|
||||||
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onTitleMenu: (TitleRecord) -> Unit,
|
||||||
|
onResumeMenu: (Resumable) -> Unit,
|
||||||
|
) {
|
||||||
|
// 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
|
||||||
|
// two titles to a screen, which is a library read one row at a time.
|
||||||
|
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),
|
||||||
|
) {
|
||||||
|
// Above the grid, and only when there is something in it: what a viewer
|
||||||
|
// opening the app wants is almost always the thing they were part way
|
||||||
|
// through. Inside the grid rather than pinned over it, so the shelf
|
||||||
|
// scrolls away with the rest.
|
||||||
|
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||||
|
featured?.let { title ->
|
||||||
|
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
FeaturedBanner(
|
||||||
|
title,
|
||||||
|
resume = resumable.firstOrNull { it.title.key == title.key },
|
||||||
|
onPlay = { onResume(title, it) },
|
||||||
|
onOpen = { onTitle(title) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.query.isBlank() && resumable.isNotEmpty()) {
|
||||||
|
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||||
|
ContinueCard(
|
||||||
|
entry,
|
||||||
|
onClick = { onResume(entry.title, entry.index) },
|
||||||
|
onLongClick = { onResumeMenu(entry) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.query.isBlank()) {
|
||||||
|
shelves.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||||
|
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(tiles, key = { it.title.key }) { tile ->
|
||||||
|
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
|
||||||
|
* watched, one of the titles the provider has a backdrop for, turning over
|
||||||
|
* once a day — the desktop's `featured`.
|
||||||
|
*/
|
||||||
|
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
|
||||||
|
resumable.firstOrNull()?.let { return it.title }
|
||||||
|
val pictured = titles.filter { it.backdropUrl != null }
|
||||||
|
if (pictured.isEmpty()) return null
|
||||||
|
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Large art behind the name, rating, genres and three lines of synopsis. */
|
||||||
|
@Composable
|
||||||
|
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
title.displayName,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
|
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||||
|
).joinToString(" · ").ifEmpty { title.caption() },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
|
title.overview?.let {
|
||||||
|
Text(
|
||||||
|
it.trim(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(if (resume != null) "Resume" else "Play")
|
||||||
|
}
|
||||||
|
TonalButton(onClick = onOpen) { Text("More info") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
||||||
|
@Composable
|
||||||
|
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(220.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
|
entry.title.displayName,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = entry.episode.thumbnailSource,
|
||||||
|
)
|
||||||
|
entry.episode.progress?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
entry.title.displayName,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
private fun PosterTile(
|
||||||
|
title: TitleRecord,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
folded: Int = 0,
|
||||||
|
shared: Boolean = true,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.posterUrl ?: title.backdropUrl,
|
||||||
|
title.displayName,
|
||||||
|
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clip(MaterialTheme.shapes.medium),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
title.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// Two lines, not one: at a large font scale "2023 · 7 episodes" does
|
||||||
|
// not fit a poster's width, and one line cut it to "2023 · 7".
|
||||||
|
Text(
|
||||||
|
title.caption(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2023 · 11 episodes", or "1988 · Film" — never "1 episodes". */
|
||||||
|
private fun TitleRecord.caption(): String = listOfNotNull(
|
||||||
|
(metadataYear ?: year)?.toString(),
|
||||||
|
when {
|
||||||
|
(metadataKind ?: kind) == TitleType.FILM && episodeCount == 1uL -> "Film"
|
||||||
|
episodeCount == 1uL -> "1 episode"
|
||||||
|
else -> "$episodeCount episodes"
|
||||||
|
},
|
||||||
|
).joinToString(" · ")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "12 min left", from where the episode was left and how far through that is.
|
||||||
|
* Null when either is unknown — a guess would be worse than no figure.
|
||||||
|
*/
|
||||||
|
private fun EpisodeRecord.timeLeft(): String? {
|
||||||
|
val at = resumeAt ?: return null
|
||||||
|
val fraction = progress?.takeIf { it > 0.0 && it < 1.0 } ?: return null
|
||||||
|
val minutes = ((at / fraction - at) / 60).toInt()
|
||||||
|
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
||||||
|
}
|
||||||
@@ -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,19 @@ 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.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 +76,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 +86,98 @@ internal fun RemoteArtwork(
|
|||||||
description: String,
|
description: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
|
labelled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
val enabled = LocalArtworkLoading.current
|
||||||
LaunchedEffect(url, fallback) {
|
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
LaunchedEffect(url, fallback, enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
image = ArtworkLoad.Missing
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||||
|
?.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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.background
|
||||||
|
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.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
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.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import uniffi.pstr_android.FlavorChoice
|
||||||
|
import uniffi.pstr_android.AppearanceRecord
|
||||||
|
import uniffi.pstr_android.TitleNamesChoice
|
||||||
|
import uniffi.pstr_android.AccentChoice
|
||||||
|
import uniffi.pstr_android.PlaybackPrefsRecord
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
onMatchAgain: () -> Unit,
|
||||||
|
onClearCache: () -> Unit,
|
||||||
|
onRemoveAllOffline: () -> Unit,
|
||||||
|
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 settings = remember { SettingsStore(context) }
|
||||||
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||||
|
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||||
|
// Playback preferences are the shared store's, not this app's: they are the
|
||||||
|
// same file the desktop client reads, so a language chosen on one is the
|
||||||
|
// language the other starts in.
|
||||||
|
var prefs by remember { mutableStateOf<PlaybackPrefsRecord?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
|
||||||
|
val next = change(prefs ?: return)
|
||||||
|
prefs = next
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||||
|
.onSuccess { prefs = it }
|
||||||
|
}
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
var showMetadata by remember { mutableStateOf(false) }
|
||||||
|
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||||
|
// One row per area on the first page, each opening its own page: grouped
|
||||||
|
// on one page, the storage controls sat a long scroll below playback, and
|
||||||
|
// every setting added made the rest harder to find.
|
||||||
|
var page by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
|
BackHandler(enabled = page != null) { page = null }
|
||||||
|
val back: @Composable () -> Unit = {
|
||||||
|
IconButton(onClick = { page = null }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (page) {
|
||||||
|
null -> TabPage("Settings", padding) { body ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
|
SettingsPage.entries.forEach { entry ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(entry.title) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
when (entry) {
|
||||||
|
SettingsPage.Playback -> "Autoplay, languages and decoding"
|
||||||
|
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
|
||||||
|
"${state.storage.offlineCount} offline"
|
||||||
|
SettingsPage.Appearance -> "Palette, accent and gradient"
|
||||||
|
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
|
||||||
|
"On · ${state.metadataSettings.provider.displayName()}"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
}
|
||||||
|
SettingsPage.About -> "Version $version · licences"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { page = entry },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
|
||||||
|
prefs?.let { current ->
|
||||||
|
SettingSwitch(
|
||||||
|
"Autoplay next episode",
|
||||||
|
"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) }
|
||||||
|
}
|
||||||
|
// Preferences, not filters: a file with nothing in the language
|
||||||
|
// plays its own default track. A show that has been given its
|
||||||
|
// own choice keeps it.
|
||||||
|
LanguageSetting("Audio language", current.audioLanguage) { tag ->
|
||||||
|
update { it.copy(audioLanguage = tag) }
|
||||||
|
}
|
||||||
|
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
|
||||||
|
update { it.copy(subtitleLanguage = tag) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"Hardware decoding",
|
||||||
|
"Turn off if video shows green or torn frames",
|
||||||
|
hardwareDecoding,
|
||||||
|
) {
|
||||||
|
hardwareDecoding = it
|
||||||
|
settings.hardwareDecoding = it
|
||||||
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"Background audio",
|
||||||
|
"Keeps playing with the app closed, from the notification",
|
||||||
|
backgroundAudio,
|
||||||
|
) {
|
||||||
|
backgroundAudio = it
|
||||||
|
settings.backgroundAudio = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Delete all offline episodes?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
|
||||||
|
"will be removed from this device. Watch history is kept, and anything " +
|
||||||
|
"deleted can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
legalDocument?.let { document ->
|
||||||
|
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
|
||||||
|
}
|
||||||
|
if (showMetadata) {
|
||||||
|
MetadataSettingsDialog(
|
||||||
|
current = state.metadataSettings,
|
||||||
|
onDismiss = { showMetadata = false },
|
||||||
|
onSave = { enabled, provider, language, key ->
|
||||||
|
onSaveMetadata(enabled, provider, language, key)
|
||||||
|
showMetadata = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
private fun SettingsSubPage(
|
||||||
|
page: SettingsPage,
|
||||||
|
padding: PaddingValues,
|
||||||
|
back: @Composable () -> Unit,
|
||||||
|
rows: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
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. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingSwitch(headline: String, supporting: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(supporting) },
|
||||||
|
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onChecked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row that does something when tapped: opens a dialog, clears a cache. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingAction(
|
||||||
|
headline: String,
|
||||||
|
supporting: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(supporting) },
|
||||||
|
colors = if (enabled) {
|
||||||
|
colors
|
||||||
|
} else {
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||||
|
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(enabled = enabled, onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetadataSettingsDialog(
|
||||||
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
|
var language by remember { mutableStateOf(current.language) }
|
||||||
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Metadata enrichment") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
|
||||||
|
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MetadataProvider.entries.forEach { option ->
|
||||||
|
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
|
||||||
|
else "Film and television; requires a free TMDB API key.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (provider == MetadataProvider.TMDB) {
|
||||||
|
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
apiKey,
|
||||||
|
{ apiKey = it },
|
||||||
|
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
||||||
|
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||||
|
MetadataProvider.ANI_LIST -> "AniList"
|
||||||
|
MetadataProvider.TMDB -> "TMDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LegalDocument(val title: String, val assetPath: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
|
||||||
|
LaunchedEffect(document.assetPath) {
|
||||||
|
contents = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(document.title) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
|
||||||
|
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Switch(checked = checked, onCheckedChange = onChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flavour, accent and gradients — the same three the desktop client offers.
|
||||||
|
*
|
||||||
|
* Every colour is resolved by Rust, so a swatch here is the colour the app will
|
||||||
|
* actually paint rather than an approximation of it, and the choice is stored in
|
||||||
|
* the file both clients read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppearancePicker(onNamesChanged: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
|
||||||
|
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
|
||||||
|
|
||||||
|
suspend fun repaint(next: AppearanceRecord, store: Boolean) {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
if (store) engine.setAppearance(next)
|
||||||
|
val palette = engine.previewPalette(next)
|
||||||
|
// Every accent as it would look in *this* flavour: a swatch row
|
||||||
|
// that keeps Mocha's pastels while Latte is selected is a row
|
||||||
|
// that lies about what the next tap does.
|
||||||
|
val row = AccentChoice.entries.associateWith { accent ->
|
||||||
|
Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt())
|
||||||
|
}
|
||||||
|
palette to row
|
||||||
|
}
|
||||||
|
}.onSuccess { (palette, row) ->
|
||||||
|
choice = next
|
||||||
|
swatches = row
|
||||||
|
AppearanceState.apply(palette)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } }
|
||||||
|
.onSuccess { repaint(it, store = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = choice ?: return
|
||||||
|
Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
items(FlavorChoice.entries.toList(), key = { it.name }) { flavor ->
|
||||||
|
val selected = flavor == current.flavor
|
||||||
|
if (selected) {
|
||||||
|
AccentButton(onClick = {}) { Text(flavor.label()) }
|
||||||
|
} else {
|
||||||
|
EdgedButton(onClick = {
|
||||||
|
scope.launch { repaint(current.copy(flavor = flavor), store = true) }
|
||||||
|
}) { Text(flavor.label()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Accent", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
items(AccentChoice.entries.toList(), key = { it.name }) { accent ->
|
||||||
|
val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(swatch)
|
||||||
|
.border(
|
||||||
|
width = if (accent == current.accent) 3.dp else 1.dp,
|
||||||
|
color = if (accent == current.accent) {
|
||||||
|
MaterialTheme.colorScheme.onBackground
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.clickable {
|
||||||
|
scope.launch { repaint(current.copy(accent = accent), store = true) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 ->
|
||||||
|
scope.launch { repaint(current.copy(gradients = on), store = true) }
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Off is the safer setting on a panel that bands: a slow ramp across a wide bar " +
|
||||||
|
"shows every step it is drawn from, and flat is better than striped.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
private fun FlavorChoice.label() = when (this) {
|
||||||
|
FlavorChoice.PROTON -> "Proton"
|
||||||
|
FlavorChoice.LATTE -> "Catppuccin Latte"
|
||||||
|
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
|
||||||
|
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
|
||||||
|
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
||||||
|
FlavorChoice.PERSONA5 -> "Persona 5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.FolderShared
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SharesScreen(
|
||||||
|
shares: List<ShareRecord>,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
onRepair: (String, String, String?) -> Unit,
|
||||||
|
onRefresh: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
incomingLink: String?,
|
||||||
|
onIncomingLinkTaken: () -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
TabPage("Shares", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
if (shares.isEmpty()) {
|
||||||
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
|
} else {
|
||||||
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
|
items(shares, key = { it.id }) { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
onRefresh = { onRefresh(share.id) },
|
||||||
|
onRepair = { repairing = share },
|
||||||
|
onRemove = { removing = 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAdd || incomingLink != null) {
|
||||||
|
AddShareDialog(
|
||||||
|
initialUrl = incomingLink.orEmpty(),
|
||||||
|
onDismiss = { showAdd = false; onIncomingLinkTaken() },
|
||||||
|
onAdd = onAdd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repairing?.let { share ->
|
||||||
|
RepairShareDialog(
|
||||||
|
share = share,
|
||||||
|
onDismiss = { repairing = null },
|
||||||
|
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
removing?.let { share ->
|
||||||
|
// Asked, because it cannot be undone: the link's secret, its offline
|
||||||
|
// files and its watch positions all go (`Catalog::remove_share`).
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { removing = null },
|
||||||
|
title = { Text("Remove ${share.name}?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Its titles leave the library, and its offline episodes and watch " +
|
||||||
|
"progress are deleted. Adding the link again starts it fresh.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One share: its name, how it is unlocked, and its actions behind a menu.
|
||||||
|
*
|
||||||
|
* Three differently styled controls beside the name squeezed it into a column
|
||||||
|
* a word wide; one icon leaves the row its width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShareRow(
|
||||||
|
share: ShareRecord,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onRepair: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link")
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.FolderShared,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Actions for ${share.name}")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
// One share, not the library: a link that has just had files
|
||||||
|
// added should not cost a walk of every other one.
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Refresh") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRefresh() },
|
||||||
|
)
|
||||||
|
// Not a remove: a share whose secret has become unreadable
|
||||||
|
// cannot be removed either, since removal deletes a secret
|
||||||
|
// the store can no longer touch.
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Re-enter link") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRepair() },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Remove") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRemove() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enter the link for a share the app can no longer decrypt its secret for.
|
||||||
|
*
|
||||||
|
* It has to be the *same* link — a different token is a different share, and
|
||||||
|
* repointing this one at it would leave every catalog row and every offline file
|
||||||
|
* describing something that is not there. Rust enforces that; this only says so.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RepairShareDialog(
|
||||||
|
share: ShareRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRepair: (String, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Re-enter link for ${share.name}") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"The stored credentials for this share cannot be read — usually after a " +
|
||||||
|
"screen-lock change or a device restore. Entering the same link again " +
|
||||||
|
"restores access without losing the library or anything downloaded.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onRepair(url.trim(), password); onDismiss() },
|
||||||
|
enabled = url.isNotBlank(),
|
||||||
|
) { Text("Restore") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddShareDialog(
|
||||||
|
initialUrl: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Add Proton Drive share") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
// The URL fragment is a secret. Password input disables
|
||||||
|
// IME learning/suggestions while normal long-press paste
|
||||||
|
// remains available.
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
||||||
|
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||||
|
) { Text("Add") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
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.background
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import io.narl.protonstream.ui.theme.solid
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.MatchRecord
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material.icons.filled.Replay
|
||||||
|
import androidx.compose.material.icons.filled.DownloadDone
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TitleScreen(
|
||||||
|
title: TitleRecord,
|
||||||
|
playerReady: Boolean,
|
||||||
|
onPlay: (List<EpisodeRecord>, Int) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPreferenceError: (Throwable) -> Unit,
|
||||||
|
onMetadataChanged: () -> Unit,
|
||||||
|
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
/** Every title of its franchise, in release order, this one included. */
|
||||||
|
franchise: List<TitleRecord> = emptyList(),
|
||||||
|
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
|
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// the same order the desktop client uses.
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||||
|
val upNext = playlist.getOrNull(nextUp)
|
||||||
|
// One season on screen at a time, opened on the one Play would land in: a
|
||||||
|
// forty-episode show as stacked sections is one long scroll to the part
|
||||||
|
// that matters.
|
||||||
|
var seasonIndex by remember(title.key) {
|
||||||
|
mutableStateOf(
|
||||||
|
title.seasons.indexOfFirst { season -> season.episodes.any { it.linkId == upNext?.linkId } }
|
||||||
|
.coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val season = title.seasons.getOrNull(seasonIndex)
|
||||||
|
// Live download state per episode, so a row can show progress and be paused
|
||||||
|
// without a trip to the Downloads tab.
|
||||||
|
val work by WorkManager.getInstance(context)
|
||||||
|
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||||
|
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||||
|
|
||||||
|
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||||
|
// button and synopsis lines two thousand pixels wide are read by turning
|
||||||
|
// the head. The backdrop still spans the window, but no taller than about
|
||||||
|
// 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),
|
||||||
|
) {
|
||||||
|
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||||
|
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item(key = "backdrop") {
|
||||||
|
// Edge to edge, fading into the page: the art is the first thing
|
||||||
|
// the page says, and a framed thumbnail with a margin round it read
|
||||||
|
// as one card among the many below it.
|
||||||
|
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.45f to Color.Transparent,
|
||||||
|
1f to MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "heading") {
|
||||||
|
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||||
|
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
// The other names it goes by: the share's when the shown one
|
||||||
|
// is the provider's, and the original-language one.
|
||||||
|
listOfNotNull(
|
||||||
|
title.name.takeIf { it != title.displayName },
|
||||||
|
title.originalName?.takeIf { it != title.displayName && it != title.canonicalName },
|
||||||
|
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
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)) {
|
||||||
|
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
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (franchise.size > 1) {
|
||||||
|
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") {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||||
|
FilterChip(
|
||||||
|
selected = index == seasonIndex,
|
||||||
|
onClick = { seasonIndex = index },
|
||||||
|
label = { Text(entry.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
season?.let {
|
||||||
|
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showMatch) {
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { showMatch = false },
|
||||||
|
onChanged = { showMatch = false; onMetadataChanged() },
|
||||||
|
onError = onPreferenceError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
@Composable
|
||||||
|
private fun TitleAction(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `S03E38`, `E38`, or the row's place in the season.
|
||||||
|
*
|
||||||
|
* Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
|
||||||
|
* fallback to the season folder's number. The last case is deliberately *not*
|
||||||
|
* printed as an episode number — nothing in the name numbered this file, and
|
||||||
|
* `#4` says "fourth in the list" where `E04` would be a claim about the show.
|
||||||
|
* Some ordinal is required regardless: a season of rows that all read the same
|
||||||
|
* is a season the viewer cannot navigate at all.
|
||||||
|
*/
|
||||||
|
private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String {
|
||||||
|
val season = this.season ?: fallbackSeason
|
||||||
|
val number = this.number
|
||||||
|
return when {
|
||||||
|
season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt())
|
||||||
|
number != null -> "E%02d".format(number.toInt())
|
||||||
|
else -> "#${position + 1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One episode: play it, see where it was left, and drive its download.
|
||||||
|
*
|
||||||
|
* The row *is* the play button — the still, the number and the name are one
|
||||||
|
* target, which is what lets the trailing controls be two icons in a fixed
|
||||||
|
* column rather than a second row of buttons under every episode.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeRow(
|
||||||
|
episode: EpisodeRecord,
|
||||||
|
numbering: String,
|
||||||
|
position: Int,
|
||||||
|
download: RetainedDownload?,
|
||||||
|
playerReady: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onSetWatched: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download?.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
val heading = episode.heading(position)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = playerReady, onClick = onPlay)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// With metadata off the provider has no still, and Proton's own
|
||||||
|
// thumbnail is a frame of this very episode. Where it was left sits
|
||||||
|
// on the still's lower edge, the way every streaming app shows it.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl,
|
||||||
|
heading,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
heading,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
// Seen episodes stay legible but stop competing with the
|
||||||
|
// one the viewer has not watched yet.
|
||||||
|
color = if (episode.watched) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
numbering,
|
||||||
|
episode.size?.let(::formatBytes),
|
||||||
|
episode.airDate,
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Watched is a judgement the viewer is allowed to overrule: a
|
||||||
|
// half-watched episode they are done with, or one the 90 % rule
|
||||||
|
// marked seen because they sat through the credits.
|
||||||
|
IconButton(onClick = { onSetWatched(!episode.watched) }) {
|
||||||
|
Icon(
|
||||||
|
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
|
||||||
|
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
|
||||||
|
tint = if (episode.watched) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// One slot, always the same width, whatever state the download
|
||||||
|
// is in — including "there is nothing to say", where an empty
|
||||||
|
// box keeps the rows above and below it aligned.
|
||||||
|
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
when {
|
||||||
|
episode.offline -> Icon(
|
||||||
|
Icons.Default.DownloadDone,
|
||||||
|
contentDescription = "Saved offline",
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
running -> IconButton(onClick = { onPause(download!!) }) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
|
}
|
||||||
|
download != null -> IconButton(onClick = { onResume(download) }) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Resume download")
|
||||||
|
}
|
||||||
|
else -> IconButton(onClick = onDownload) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episode.providerOverview?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
||||||
|
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ChangeMatchDialog(
|
||||||
|
title: TitleRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
onError: (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Change match") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
||||||
|
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
||||||
|
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
|
||||||
|
searching = true
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
|
||||||
|
}.onSuccess { options = it }.onFailure(onError)
|
||||||
|
searching = false
|
||||||
|
}
|
||||||
|
}) { Text(if (searching) "Searching…" else "Search") }
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||||
|
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||||
|
EdgedButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||||
|
}.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(option.name, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
QuietButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||||
|
.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
}) { Text("Forget match") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -168,11 +168,21 @@ internal fun AccentProgress(
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
|
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier, buffered: Float = 0f) {
|
||||||
val brush = accentBrush()
|
val brush = accentBrush()
|
||||||
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
||||||
val radius = CornerRadius(size.height / 2)
|
val radius = CornerRadius(size.height / 2)
|
||||||
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
||||||
|
// What is already read ahead, lighter than the rest and under the
|
||||||
|
// played part: a jump that lands in it costs no fetch.
|
||||||
|
val ahead = size.width * buffered.coerceIn(0f, 1f)
|
||||||
|
if (ahead >= size.height) {
|
||||||
|
drawRoundRect(
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
size = Size(ahead, size.height),
|
||||||
|
cornerRadius = radius,
|
||||||
|
)
|
||||||
|
}
|
||||||
val done = size.width * state.coercedValueAsFraction
|
val done = size.width * state.coercedValueAsFraction
|
||||||
if (done >= size.height) {
|
if (done >= size.height) {
|
||||||
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ package io.narl.protonstream.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import io.narl.protonstream.R
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inter, the desktop client's face, from the same two files.
|
||||||
|
*
|
||||||
|
* Only Regular and SemiBold ship, as on the desktop. A `Medium` role resolves
|
||||||
|
* to Regular by the usual weight matching rather than to a synthesised bold,
|
||||||
|
* which is how the desktop draws it too.
|
||||||
|
*
|
||||||
|
* Above [ProtonTypography] on purpose: top-level properties initialise in
|
||||||
|
* file order, and a ramp built first would capture this as null and fall back
|
||||||
|
* to the system face without a word.
|
||||||
|
*/
|
||||||
|
internal val Inter = FontFamily(
|
||||||
|
Font(R.font.inter_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type ramp, which is the desktop client's type ramp.
|
* The type ramp, which is the desktop client's type ramp.
|
||||||
*
|
*
|
||||||
@@ -72,4 +91,5 @@ private fun role(size: Float, weight: FontWeight) = TextStyle(
|
|||||||
fontSize = size.sp,
|
fontSize = size.sp,
|
||||||
lineHeight = (size * 1.35f).sp,
|
lineHeight = (size * 1.35f).sp,
|
||||||
fontWeight = weight,
|
fontWeight = weight,
|
||||||
|
fontFamily = Inter,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
The launcher tile: packaging/io.narl.proton-stream.svg's face gradient, on
|
||||||
|
the full 108dp canvas because the launcher cuts its own shape out of it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:type="linear"
|
||||||
|
android:startX="0" android:startY="0"
|
||||||
|
android:endX="108" android:endY="108">
|
||||||
|
<item android:offset="0" android:color="#FFA48BFF" />
|
||||||
|
<item android:offset="0.55" android:color="#FF7552FF" />
|
||||||
|
<item android:offset="1" android:color="#FF5B36F2" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -1,6 +1,34 @@
|
|||||||
|
<!--
|
||||||
|
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
|
||||||
|
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"
|
||||||
android:width="108dp" android:height="108dp"
|
android:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
|
<group
|
||||||
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-16" android:translateY="-8">
|
||||||
|
<group android:translateY="3">
|
||||||
|
<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" />
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path 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">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:type="linear"
|
||||||
|
android:startX="0" android:startY="36"
|
||||||
|
android:endX="0" android:endY="88">
|
||||||
|
<item android:offset="0" android:color="#FFFFFFFF" />
|
||||||
|
<item android:offset="1" android:color="#FFECE6FF" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!-- The foreground's glyph as one flat shape, for themed launcher icons. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-16" android:translateY="-8">
|
||||||
|
<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" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<!--
|
<!--
|
||||||
The status-bar glyph. Android tints small icons to a single colour and drops
|
The status-bar glyph. Android tints small icons to a single colour and drops
|
||||||
everything that is not opaque, so this is the launcher mark reduced to one
|
everything that is not opaque, so this is the launcher's sliced play mark as
|
||||||
white silhouette rather than the two-tone drawable.
|
one white silhouette, scaled from the SVG's 128-unit space onto 24dp.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp" android:height="24dp"
|
android:width="24dp" android:height="24dp"
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
<path
|
<group
|
||||||
android:fillColor="#FFFFFFFF"
|
android:pivotX="70" android:pivotY="62"
|
||||||
android:fillType="evenOdd"
|
android:scaleX="0.37" android:scaleY="0.37"
|
||||||
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
|
android:translateX="-58" android:translateY="-50">
|
||||||
|
<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" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">proton-stream</string>
|
<!-- Settings, recents, notifications and the share sheet: room for the full name. -->
|
||||||
|
<string name="app_name">Proton Stream</string>
|
||||||
|
<!--
|
||||||
|
Under the launcher icon, where about ten characters fit before the
|
||||||
|
launcher cuts the label off; "proton-stream" showed as "proton-st…".
|
||||||
|
-->
|
||||||
|
<string name="launcher_name">Stream</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IncomingShareLinkTest {
|
||||||
|
private val link = "https://drive.proton.me/urls/ABC123#s3cr3t"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a viewed share link is taken whole, fragment included`() {
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_VIEW, link, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a viewed url that is not an https share link is ignored`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "https://drive.proton.me/u/0/", null))
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "http://drive.proton.me/urls/ABC123#s3cr3t", null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared message yields the link inside it`() {
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "Season two is up: $link enjoy"))
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "(<$link>)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared message without a link yields nothing`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_SEND, null, "no link here"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `other actions are ignored`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_MAIN, link, link))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(" - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.Locale
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
class HistoryTest {
|
||||||
|
private fun episode(link: String, watched: Boolean, progress: Double?, lastPlayed: Long) = EpisodeRecord(
|
||||||
|
shareId = "s", volumeId = "v", linkId = link, name = "$link.mkv", label = link, detail = "$link.mkv",
|
||||||
|
season = 1u, number = null, size = null, progress = progress, resumeAt = progress?.let { it * 1000 },
|
||||||
|
watched = watched, offline = false, providerName = null, providerOverview = null, stillUrl = null,
|
||||||
|
airDate = null, lastPlayed = lastPlayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun title(key: String, vararg episodes: EpisodeRecord) = TitleRecord(
|
||||||
|
key = key, name = key, year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||||
|
episodeCount = episodes.size.toULong(), canonicalName = null, originalName = null, overview = null,
|
||||||
|
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||||
|
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||||
|
manualMatch = false, seasons = 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `history is every started or finished episode, newest first, across titles`() {
|
||||||
|
val titles = listOf(
|
||||||
|
title(
|
||||||
|
"a",
|
||||||
|
episode("a1", watched = true, progress = 1.0, lastPlayed = 30),
|
||||||
|
// Marked unwatched again: a row at zero, which is not a play.
|
||||||
|
episode("a2", watched = false, progress = 0.0, lastPlayed = 40),
|
||||||
|
episode("a3", watched = false, progress = null, lastPlayed = 0),
|
||||||
|
),
|
||||||
|
title("b", episode("b1", watched = false, progress = 0.4, lastPlayed = 50)),
|
||||||
|
)
|
||||||
|
val played = history(titles)
|
||||||
|
assertEquals(listOf("b1", "a1"), played.map { it.episode.linkId })
|
||||||
|
assertEquals(0, played.last().index)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days are headed relative to today, then by date`() {
|
||||||
|
val today = LocalDate.of(2026, 10, 3) // a Saturday
|
||||||
|
val english = Locale.ENGLISH
|
||||||
|
assertEquals("Today", dayHeading(today, today, english))
|
||||||
|
assertEquals("Yesterday", dayHeading(today.minusDays(1), today, english))
|
||||||
|
assertEquals("Monday", dayHeading(LocalDate.of(2026, 9, 28), today, english))
|
||||||
|
assertEquals("26 September", dayHeading(LocalDate.of(2026, 9, 26), today, english))
|
||||||
|
assertEquals("3 October 2025", dayHeading(LocalDate.of(2025, 10, 3), today, english))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.work.testing.WorkManagerTestInitHelper
|
||||||
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
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 java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.ShelfRecord
|
||||||
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.TileRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every screen, drawn from fixed records, against a committed image.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* 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 generated
|
||||||
|
* placeholder, which is also what a title without metadata looks like on a
|
||||||
|
* phone.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
|
||||||
|
class ScreenshotTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
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) {
|
||||||
|
compose.setContent {
|
||||||
|
ProtonStreamTheme {
|
||||||
|
CompositionLocalProvider(LocalArtworkLoading provides false) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library on a phone`() = capture("library-phone") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, 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
|
||||||
|
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||||
|
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||||
|
// reads it from, so the scale is set there.
|
||||||
|
val density = LocalDensity.current
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty library`() = capture("library-empty") {
|
||||||
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||||
|
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
|
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the playback settings`() = capture("settings-playback") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the storage settings`() = capture("settings-storage") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the history page`() = capture("history-phone") {
|
||||||
|
// Fixed day and zone: "Today" and "Yesterday" are relative, and an
|
||||||
|
// image that changes with the date is no baseline.
|
||||||
|
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
|
||||||
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// `detail` is the filename unless the filename named the episode,
|
||||||
|
// which is what the bridge sends.
|
||||||
|
val file = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt())
|
||||||
|
return episodeRecord(season, number, file, watched, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun episodeRecord(season: UInt, number: UInt, file: String, watched: Boolean, progress: Double?) = EpisodeRecord(
|
||||||
|
shareId = "share",
|
||||||
|
volumeId = "volume",
|
||||||
|
linkId = "s${season}e$number",
|
||||||
|
name = file,
|
||||||
|
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
|
||||||
|
detail = file,
|
||||||
|
season = season,
|
||||||
|
number = number,
|
||||||
|
size = 1_300_000_000uL,
|
||||||
|
progress = progress,
|
||||||
|
resumeAt = progress?.let { it * 1440 },
|
||||||
|
watched = watched,
|
||||||
|
offline = false,
|
||||||
|
providerName = if (number == 1u) "Mother and Children" else null,
|
||||||
|
providerOverview = null,
|
||||||
|
stillUrl = null,
|
||||||
|
airDate = null,
|
||||||
|
// 2026-09-21 for the part-watched one, the day before for the rest.
|
||||||
|
lastPlayed = when {
|
||||||
|
progress != null -> 1_790_000_000L
|
||||||
|
watched -> 1_789_900_000L - number.toLong() * 600
|
||||||
|
else -> 0L
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||||
|
key = key,
|
||||||
|
name = name,
|
||||||
|
year = 2023u,
|
||||||
|
kind = TitleType.SERIES,
|
||||||
|
watchedCount = seasons.sumOf { s -> s.episodes.count { it.watched } }.toULong(),
|
||||||
|
episodeCount = seasons.sumOf { it.episodes.size }.toULong(),
|
||||||
|
canonicalName = null,
|
||||||
|
originalName = null,
|
||||||
|
overview = overview,
|
||||||
|
metadataProvider = null,
|
||||||
|
metadataId = null,
|
||||||
|
metadataYear = null,
|
||||||
|
metadataKind = null,
|
||||||
|
posterUrl = null,
|
||||||
|
backdropUrl = null,
|
||||||
|
rating = 8.4,
|
||||||
|
genres = listOf("Drama", "Mystery"),
|
||||||
|
providerEpisodeCount = null,
|
||||||
|
externalUrl = null,
|
||||||
|
manualMatch = false,
|
||||||
|
seasons = seasons,
|
||||||
|
displayName = name,
|
||||||
|
wideUrl = null,
|
||||||
|
formatLabel = null,
|
||||||
|
seasonLabel = null,
|
||||||
|
studios = emptyList(),
|
||||||
|
directors = emptyList(),
|
||||||
|
tags = emptyList(),
|
||||||
|
airing = false,
|
||||||
|
nextEpisode = null,
|
||||||
|
nextAiringAt = null,
|
||||||
|
franchise = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SERIES = title(
|
||||||
|
"oshi-no-ko",
|
||||||
|
"Oshi no Ko",
|
||||||
|
listOf(
|
||||||
|
SeasonRecord(1u, "Season 1", (1u..4u).map { episode(1u, it, watched = it < 3u) }),
|
||||||
|
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.",
|
||||||
|
).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)))))
|
||||||
|
.copy(kind = TitleType.FILM, year = 1988u)
|
||||||
|
|
||||||
|
val LIBRARY = AppUiState(
|
||||||
|
loading = false,
|
||||||
|
titles = listOf(
|
||||||
|
FILM,
|
||||||
|
SERIES,
|
||||||
|
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) }))),
|
||||||
|
),
|
||||||
|
// 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"))),
|
||||||
|
),
|
||||||
|
shares = listOf(
|
||||||
|
ShareRecord("anime", "anime", true),
|
||||||
|
ShareRecord("films", "films", false),
|
||||||
|
),
|
||||||
|
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 813 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 807 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -14,6 +14,7 @@ androidx-test-core = "1.7.0"
|
|||||||
androidx-test-junit = "1.3.0"
|
androidx-test-junit = "1.3.0"
|
||||||
androidx-test-runner = "1.7.0"
|
androidx-test-runner = "1.7.0"
|
||||||
ktlint-plugin = "13.1.0"
|
ktlint-plugin = "13.1.0"
|
||||||
|
roborazzi = "1.76.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||||
@@ -41,9 +42,13 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
|
|||||||
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
||||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||||
|
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
|
||||||
|
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
|
||||||
|
roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||||
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
@@ -11,12 +11,13 @@ use std::sync::{Arc, OnceLock};
|
|||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||||
|
use pstr_core::browse::Sort;
|
||||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||||
use pstr_core::chapters::{Chapter, ChapterRole};
|
use pstr_core::chapters::{Chapter, ChapterRole};
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||||
use pstr_core::metadata::{
|
use pstr_core::metadata::{
|
||||||
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
|
||||||
};
|
};
|
||||||
use pstr_core::prefs::PlaybackPrefs;
|
use pstr_core::prefs::PlaybackPrefs;
|
||||||
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
|
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
|
||||||
@@ -175,6 +176,55 @@ pub struct AppearanceRecord {
|
|||||||
pub flavor: FlavorChoice,
|
pub flavor: FlavorChoice,
|
||||||
pub accent: AccentChoice,
|
pub accent: AccentChoice,
|
||||||
pub gradients: bool,
|
pub gradients: bool,
|
||||||
|
/// Which name a title goes by. Shared with the desktop.
|
||||||
|
pub names: TitleNamesChoice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared `pstr_core::metadata::TitleNames`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||||
|
pub enum TitleNamesChoice {
|
||||||
|
/// The share's folder name.
|
||||||
|
Library,
|
||||||
|
English,
|
||||||
|
Romaji,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared `pstr_core::browse::Sort`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||||
|
pub enum LibrarySort {
|
||||||
|
Name,
|
||||||
|
Recent,
|
||||||
|
Added,
|
||||||
|
Release,
|
||||||
|
Rating,
|
||||||
|
Popularity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One tile of the library grid.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct TileRecord {
|
||||||
|
/// The title drawn.
|
||||||
|
pub key: String,
|
||||||
|
/// How many other titles of its franchise it stands for.
|
||||||
|
pub folded: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of titles that share a director, studio or genre.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ShelfRecord {
|
||||||
|
pub label: String,
|
||||||
|
pub keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library grid, ordered and folded, and the shelves above it — what
|
||||||
|
/// `pstr_core::browse` and `pstr_core::shelves` make of the library, as keys
|
||||||
|
/// into [`AndroidEngine::library`]'s titles.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ArrangementRecord {
|
||||||
|
pub tiles: Vec<TileRecord>,
|
||||||
|
/// Empty while searching: a shelf of titles the search did not find reads
|
||||||
|
/// as the search not working.
|
||||||
|
pub shelves: Vec<ShelfRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`.
|
/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`.
|
||||||
@@ -264,6 +314,10 @@ pub struct MatchRecord {
|
|||||||
pub genres: Vec<String>,
|
pub genres: Vec<String>,
|
||||||
pub episode_count: Option<u32>,
|
pub episode_count: Option<u32>,
|
||||||
pub external_url: Option<String>,
|
pub external_url: Option<String>,
|
||||||
|
/// `TitleDetails` as JSON, opaque to Kotlin: carried so that a match
|
||||||
|
/// picked by hand keeps the studios, relations and dates search found,
|
||||||
|
/// rather than losing them on the way back across the bridge.
|
||||||
|
pub details: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
@@ -323,6 +377,26 @@ pub struct TitleRecord {
|
|||||||
pub external_url: Option<String>,
|
pub external_url: Option<String>,
|
||||||
pub manual_match: bool,
|
pub manual_match: bool,
|
||||||
pub seasons: Vec<SeasonRecord>,
|
pub seasons: Vec<SeasonRecord>,
|
||||||
|
/// The name to show, as the viewer's name setting has it.
|
||||||
|
pub display_name: String,
|
||||||
|
/// The widest art there is: fanart, else the provider's banner.
|
||||||
|
pub wide_url: Option<String>,
|
||||||
|
/// `TV`, `Film`, `OVA` — the provider's format, said for a person.
|
||||||
|
pub format_label: Option<String>,
|
||||||
|
/// `Spring 2013`.
|
||||||
|
pub season_label: Option<String>,
|
||||||
|
pub studios: Vec<String>,
|
||||||
|
pub directors: Vec<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Still airing.
|
||||||
|
pub airing: bool,
|
||||||
|
/// The next episode to air and when, Unix seconds — only while that is
|
||||||
|
/// still ahead.
|
||||||
|
pub next_episode: Option<u32>,
|
||||||
|
pub next_airing_at: Option<i64>,
|
||||||
|
/// Every title of its franchise, in release order, its own key included;
|
||||||
|
/// empty for a title that stands alone.
|
||||||
|
pub franchise: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playback preferences that outlive one file, one title and one launch.
|
/// Playback preferences that outlive one file, one title and one launch.
|
||||||
@@ -717,7 +791,6 @@ struct Connection {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
library: Arc<SharedLibrary>,
|
library: Arc<SharedLibrary>,
|
||||||
open_failures: Vec<String>,
|
open_failures: Vec<String>,
|
||||||
#[allow(dead_code)]
|
|
||||||
source: StreamSource,
|
source: StreamSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +827,9 @@ pub struct AndroidEngine {
|
|||||||
/// between one episode and the next, so age — not just a share-store
|
/// between one episode and the next, so age — not just a share-store
|
||||||
/// mutation — is a reason to reauthenticate before opening anything.
|
/// mutation — is a reason to reauthenticate before opening anything.
|
||||||
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
||||||
|
/// The streaming cache's budget in bytes, for the next connection and,
|
||||||
|
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||||
|
cache_budget: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a visitor session is assumed good for without a refresh.
|
/// How long a visitor session is assumed good for without a refresh.
|
||||||
@@ -798,6 +874,7 @@ impl AndroidEngine {
|
|||||||
share_generation: AtomicU64::new(0),
|
share_generation: AtomicU64::new(0),
|
||||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||||
session_refreshed: Mutex::new(HashMap::new()),
|
session_refreshed: Mutex::new(HashMap::new()),
|
||||||
|
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,23 +1022,70 @@ impl AndroidEngine {
|
|||||||
/// table scans, a `Library::build` and a full record conversion per
|
/// table scans, a `Library::build` and a full record conversion per
|
||||||
/// keystroke.
|
/// keystroke.
|
||||||
pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> {
|
pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> {
|
||||||
let titles = self.all_title_records()?;
|
let query = search.unwrap_or_default();
|
||||||
let needle = search.unwrap_or_default().trim().to_lowercase();
|
self.with_library(|cached| {
|
||||||
if needle.is_empty() {
|
if query.trim().is_empty() {
|
||||||
return Ok(titles);
|
return cached.titles.clone();
|
||||||
}
|
}
|
||||||
Ok(titles
|
let found: std::collections::HashSet<&str> =
|
||||||
.into_iter()
|
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
||||||
.filter(|title| {
|
.into_iter()
|
||||||
title.name.to_lowercase().contains(&needle)
|
.map(|title| title.key.as_str())
|
||||||
|| title.seasons.iter().any(|season| {
|
.collect();
|
||||||
season
|
cached
|
||||||
.episodes
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
.any(|episode| episode.name.to_lowercase().contains(&needle))
|
.filter(|title| found.contains(title.key.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library grid for `search`, in `sort` order, each franchise folded
|
||||||
|
/// into one tile when `grouped` — and the shelves above it. Keys into
|
||||||
|
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
|
||||||
|
/// once.
|
||||||
|
pub fn arrangement(
|
||||||
|
&self,
|
||||||
|
search: Option<String>,
|
||||||
|
sort: LibrarySort,
|
||||||
|
grouped: bool,
|
||||||
|
) -> Result<ArrangementRecord, BridgeError> {
|
||||||
|
let query = search.unwrap_or_default();
|
||||||
|
self.with_library(|cached| {
|
||||||
|
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query);
|
||||||
|
let tiles = pstr_core::browse::arrange(
|
||||||
|
&titles,
|
||||||
|
pstr_core::browse::Order {
|
||||||
|
sort: sort_choice(sort),
|
||||||
|
grouped,
|
||||||
|
names: cached.names,
|
||||||
|
metadata: &cached.metadata,
|
||||||
|
added: &cached.added,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let shelves = if query.trim().is_empty() {
|
||||||
|
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
|
||||||
|
.into_iter()
|
||||||
|
.map(|shelf| ShelfRecord {
|
||||||
|
label: shelf.label,
|
||||||
|
keys: shelf.keys,
|
||||||
})
|
})
|
||||||
})
|
.collect()
|
||||||
.collect())
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
ArrangementRecord {
|
||||||
|
tiles: tiles
|
||||||
|
.iter()
|
||||||
|
.map(|tile| TileRecord {
|
||||||
|
key: titles[tile.lead].key.clone(),
|
||||||
|
folded: (tile.members.len() - 1) as u32,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
shelves,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
|
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
|
||||||
@@ -1177,8 +1301,14 @@ impl AndroidEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> {
|
pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> {
|
||||||
pstr_core::appearance::save(&self.dirs, &appearance_choice(appearance))
|
// The record has no grid shape — Android's is always covers — so the
|
||||||
.map_err(BridgeError::from_display)
|
// desktop's is kept as it was stored.
|
||||||
|
let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default();
|
||||||
|
let appearance = Appearance {
|
||||||
|
tiles: stored.tiles,
|
||||||
|
..appearance_choice(appearance)
|
||||||
|
};
|
||||||
|
pstr_core::appearance::save(&self.dirs, &appearance).map_err(BridgeError::from_display)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The colours the stored choice resolves to.
|
/// The colours the stored choice resolves to.
|
||||||
@@ -1279,6 +1409,22 @@ impl AndroidEngine {
|
|||||||
.map_err(BridgeError::from_display)?
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set how much the streaming cache may hold. An open connection's cache
|
||||||
|
/// evicts down to it at once; a lower budget is a request for the space
|
||||||
|
/// back, not only a limit on what comes next.
|
||||||
|
pub fn set_stream_cache_budget(&self, bytes: u64) {
|
||||||
|
self.cache_budget.store(bytes, Ordering::Release);
|
||||||
|
let source = self
|
||||||
|
.connection
|
||||||
|
.lock()
|
||||||
|
.as_ref()
|
||||||
|
.map(|open| open.source.clone());
|
||||||
|
if let Some(source) = source {
|
||||||
|
self.runtime
|
||||||
|
.spawn(async move { source.set_disk_budget(bytes).await });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the cached blocks. Rebuildable by definition: this is the one thing
|
/// Drop the cached blocks. Rebuildable by definition: this is the one thing
|
||||||
/// here that can be reclaimed without losing something the viewer chose.
|
/// here that can be reclaimed without losing something the viewer chose.
|
||||||
pub fn clear_block_cache(&self) -> Result<u64, BridgeError> {
|
pub fn clear_block_cache(&self) -> Result<u64, BridgeError> {
|
||||||
@@ -1401,44 +1547,29 @@ impl AndroidEngine {
|
|||||||
impl AndroidEngine {
|
impl AndroidEngine {
|
||||||
/// One enrichment pass over the whole library.
|
/// One enrichment pass over the whole library.
|
||||||
///
|
///
|
||||||
/// Two kinds of work, and the difference is a request saved: a title that
|
/// What to do per title is decided by `pstr_meta::service::plan`, the same
|
||||||
/// already has a good match but no episode list needs the episode request
|
/// as on the desktop. The work runs fanned out under a semaphore, and a
|
||||||
/// only. Both run fanned out under a semaphore, and a provider error counts
|
/// provider error counts against the run rather than ending it — one
|
||||||
/// against the run rather than ending it — one rate-limited title must not
|
/// rate-limited title must not leave the other thirty unenriched. This
|
||||||
/// leave the other thirty unenriched. This mirrors the desktop engine's
|
/// mirrors the desktop engine's `run_match`, which is the reference for the
|
||||||
/// `run_match`, which is the reference for the behaviour.
|
/// behaviour.
|
||||||
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
|
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
|
||||||
let service = Arc::new(self.metadata_service()?);
|
let service = Arc::new(self.metadata_service()?);
|
||||||
let provider = service.provider();
|
let provider = service.provider();
|
||||||
let (titles, stored) = self.titles_and_metadata()?;
|
let (titles, stored) = self.titles_and_metadata()?;
|
||||||
let listed = self
|
let enriched = self
|
||||||
.catalog
|
.catalog
|
||||||
.lock()
|
.lock()
|
||||||
.episode_metadata_ages()
|
.enrichment_ages()
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?;
|
||||||
|
let pending = pstr_meta::service::plan(
|
||||||
let mut pending: Vec<Work> = Vec::new();
|
titles,
|
||||||
for title in titles {
|
&stored,
|
||||||
let record = stored.get(&title.key);
|
&enriched,
|
||||||
// A hand-picked entry is never searched for again, not even by
|
provider,
|
||||||
// "match again" — that button means "the automatic answers are
|
service.has_details(),
|
||||||
// wrong", and re-deciding the one title the viewer already fixed by
|
force,
|
||||||
// hand is the opposite of what they asked for. Its episode list is
|
);
|
||||||
// still fetched below if it is missing.
|
|
||||||
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
|
|
||||||
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
|
|
||||||
pending.push(Work::Match(title));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let has_episodes = listed
|
|
||||||
.get(&title.key)
|
|
||||||
.is_some_and(|(asked, _)| *asked == provider);
|
|
||||||
if let Some(found) = record.and_then(|record| record.metadata.clone())
|
|
||||||
&& !has_episodes
|
|
||||||
{
|
|
||||||
pending.push(Work::Episodes(title, Box::new(found)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
|
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
|
||||||
let mut lookups = tokio::task::JoinSet::new();
|
let mut lookups = tokio::task::JoinSet::new();
|
||||||
@@ -1447,32 +1578,15 @@ impl AndroidEngine {
|
|||||||
let permits = Arc::clone(&permits);
|
let permits = Arc::clone(&permits);
|
||||||
lookups.spawn(async move {
|
lookups.spawn(async move {
|
||||||
let _permit = permits.acquire().await.ok()?;
|
let _permit = permits.acquire().await.ok()?;
|
||||||
Some(match work {
|
Some(service.work(&work).await.map_err(|error| error.to_string()))
|
||||||
Work::Match(title) => match service.record(&title).await {
|
|
||||||
Ok(record) => {
|
|
||||||
// Only for a title that matched: there is no id to
|
|
||||||
// ask about otherwise.
|
|
||||||
let episodes = match &record.metadata {
|
|
||||||
Some(found) => service.title_episodes(&title, found).await,
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
Ok((record.title_key.clone(), Some(record), episodes))
|
|
||||||
}
|
|
||||||
Err(error) => Err(error.to_string()),
|
|
||||||
},
|
|
||||||
Work::Episodes(title, found) => {
|
|
||||||
let episodes = service.title_episodes(&title, &found).await;
|
|
||||||
Ok((title.key, None, episodes))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut summary = MatchSummary::default();
|
let mut summary = MatchSummary::default();
|
||||||
while let Some(joined) = lookups.join_next().await {
|
while let Some(joined) = lookups.join_next().await {
|
||||||
match joined.map_err(BridgeError::from_display)? {
|
match joined.map_err(BridgeError::from_display)? {
|
||||||
Some(Ok((title_key, record, episodes))) => {
|
Some(Ok(outcome)) => {
|
||||||
if let Some(record) = &record {
|
if let Some(record) = &outcome.record {
|
||||||
if record.metadata.is_some() {
|
if record.metadata.is_some() {
|
||||||
summary.matched += 1;
|
summary.matched += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -1483,16 +1597,16 @@ impl AndroidEngine {
|
|||||||
log::warn!("store metadata for {}: {error}", record.title_key);
|
log::warn!("store metadata for {}: {error}", record.title_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !episodes.is_empty() {
|
if let Some(enrichment) = &outcome.enrichment {
|
||||||
summary.episodes += episodes.len() as u32;
|
summary.episodes += enrichment.episodes.len() as u32;
|
||||||
let stored = self.catalog.lock().set_episode_metadata(
|
let stored = self.catalog.lock().set_enrichment(
|
||||||
&title_key,
|
&outcome.title_key,
|
||||||
provider,
|
provider,
|
||||||
now(),
|
now(),
|
||||||
&episodes,
|
enrichment,
|
||||||
);
|
);
|
||||||
if let Err(error) = stored {
|
if let Err(error) = stored {
|
||||||
log::warn!("store episodes for {title_key}: {error}");
|
log::warn!("store enrichment for {}: {error}", outcome.title_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1520,13 +1634,19 @@ impl AndroidEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every title as a bridge record, rebuilt only when the catalog moved.
|
/// Every title as a bridge record, rebuilt only when the catalog moved.
|
||||||
fn all_title_records(&self) -> Result<Vec<TitleRecord>, BridgeError> {
|
/// Run `read` over the library, its metadata and its records, rebuilt
|
||||||
|
/// only when the catalog or the name setting has changed since.
|
||||||
|
fn with_library<T>(&self, read: impl FnOnce(&CachedLibrary) -> T) -> Result<T, BridgeError> {
|
||||||
|
let names = pstr_core::appearance::load(&self.dirs)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.names;
|
||||||
let catalog = self.catalog.lock();
|
let catalog = self.catalog.lock();
|
||||||
let writes = catalog.writes();
|
let writes = catalog.writes();
|
||||||
if let Some(cached) = self.library_cache.lock().as_ref()
|
if let Some(cached) = self.library_cache.lock().as_ref()
|
||||||
&& cached.writes == writes
|
&& cached.writes == writes
|
||||||
|
&& cached.names == names
|
||||||
{
|
{
|
||||||
return Ok(cached.titles.clone());
|
return Ok(read(cached));
|
||||||
}
|
}
|
||||||
|
|
||||||
let files = catalog.all_files().map_err(BridgeError::from_display)?;
|
let files = catalog.all_files().map_err(BridgeError::from_display)?;
|
||||||
@@ -1540,26 +1660,61 @@ impl AndroidEngine {
|
|||||||
let offline = catalog
|
let offline = catalog
|
||||||
.all_offline_files()
|
.all_offline_files()
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?;
|
||||||
|
let first_seen = catalog.first_seen().map_err(BridgeError::from_display)?;
|
||||||
drop(catalog);
|
drop(catalog);
|
||||||
|
|
||||||
let library = Library::build(files, &watch);
|
let library = Library::build(files, &watch);
|
||||||
|
let all: Vec<&Title> = library.titles.iter().collect();
|
||||||
|
let franchises: HashMap<String, Vec<String>> = pstr_core::franchise::group(&all, &metadata)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|franchise| franchise.members.len() > 1)
|
||||||
|
.flat_map(|franchise| {
|
||||||
|
let keys: Vec<String> = franchise
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|&index| all[index].key.clone())
|
||||||
|
.collect();
|
||||||
|
keys.clone().into_iter().map(move |key| (key, keys.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let now = now();
|
||||||
let titles: Vec<TitleRecord> = library
|
let titles: Vec<TitleRecord> = library
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|title| {
|
.map(|title| {
|
||||||
title_record(
|
let mut record = title_record(
|
||||||
title,
|
title,
|
||||||
&offline,
|
&offline,
|
||||||
metadata.get(&title.key),
|
metadata.get(&title.key),
|
||||||
guides.get(&title.key),
|
guides.get(&title.key),
|
||||||
)
|
);
|
||||||
|
record.display_name =
|
||||||
|
pstr_core::browse::display_name(title, &metadata, names).to_owned();
|
||||||
|
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
|
||||||
|
if let Some(details) = metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.and_then(|found| found.details.as_ref())
|
||||||
|
&& let Some((episode, at)) = details.next_airing(now)
|
||||||
|
{
|
||||||
|
record.next_episode = Some(episode);
|
||||||
|
record.next_airing_at = Some(at);
|
||||||
|
}
|
||||||
|
record
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
*self.library_cache.lock() = Some(CachedLibrary {
|
let added = library.added_at(&first_seen);
|
||||||
|
let cached = CachedLibrary {
|
||||||
writes,
|
writes,
|
||||||
titles: titles.clone(),
|
names,
|
||||||
});
|
library,
|
||||||
Ok(titles)
|
metadata,
|
||||||
|
added,
|
||||||
|
titles,
|
||||||
|
};
|
||||||
|
let answer = read(&cached);
|
||||||
|
*self.library_cache.lock() = Some(cached);
|
||||||
|
Ok(answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn titles_and_metadata(
|
fn titles_and_metadata(
|
||||||
@@ -1723,8 +1878,10 @@ impl AndroidEngine {
|
|||||||
let opener = Arc::new(LibraryOpener::new(Arc::clone(&library)));
|
let opener = Arc::new(LibraryOpener::new(Arc::clone(&library)));
|
||||||
let source = StreamSource::new(
|
let source = StreamSource::new(
|
||||||
opener,
|
opener,
|
||||||
StreamConfig::default()
|
StreamConfig::default().with_disk_cache(
|
||||||
.with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache())),
|
DiskCacheConfig::new(self.dirs.block_cache())
|
||||||
|
.with_budget(self.cache_budget.load(Ordering::Acquire)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?;
|
||||||
@@ -2316,17 +2473,14 @@ fn normalized_language(language: Option<String>) -> Option<String> {
|
|||||||
/// The library conversion, valid while the catalog has not been written to.
|
/// The library conversion, valid while the catalog has not been written to.
|
||||||
struct CachedLibrary {
|
struct CachedLibrary {
|
||||||
writes: u64,
|
writes: u64,
|
||||||
|
names: TitleNames,
|
||||||
|
library: Library,
|
||||||
|
metadata: HashMap<String, MetadataRecord>,
|
||||||
|
/// When each title last gained a file.
|
||||||
|
added: HashMap<String, i64>,
|
||||||
titles: Vec<TitleRecord>,
|
titles: Vec<TitleRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One title's share of an enrichment pass.
|
|
||||||
enum Work {
|
|
||||||
/// Search for it, and take its episodes if it matches.
|
|
||||||
Match(Title),
|
|
||||||
/// It is already matched; only the episode list is missing.
|
|
||||||
Episodes(Title, Box<TitleMetadata>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many provider lookups may be in flight at once.
|
/// How many provider lookups may be in flight at once.
|
||||||
///
|
///
|
||||||
/// Deliberately small. Both providers rate-limit by client, and a phone
|
/// Deliberately small. Both providers rate-limit by client, and a phone
|
||||||
@@ -2378,9 +2532,29 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
|
|||||||
Accent::Red => AccentChoice::Red,
|
Accent::Red => AccentChoice::Red,
|
||||||
},
|
},
|
||||||
gradients: appearance.gradients,
|
gradients: appearance.gradients,
|
||||||
|
names: match appearance.names {
|
||||||
|
TitleNames::Library => TitleNamesChoice::Library,
|
||||||
|
TitleNames::English => TitleNamesChoice::English,
|
||||||
|
TitleNames::Romaji => TitleNamesChoice::Romaji,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sort_choice(sort: LibrarySort) -> Sort {
|
||||||
|
match sort {
|
||||||
|
LibrarySort::Name => Sort::Name,
|
||||||
|
LibrarySort::Recent => Sort::Recent,
|
||||||
|
LibrarySort::Added => Sort::Added,
|
||||||
|
LibrarySort::Release => Sort::Release,
|
||||||
|
LibrarySort::Rating => Sort::Rating,
|
||||||
|
LibrarySort::Popularity => Sort::Popularity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many shelves the library shows above the grid — fewer than the
|
||||||
|
/// desktop's four, because on a phone each is a screen's height.
|
||||||
|
const SHELVES: usize = 3;
|
||||||
|
|
||||||
fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||||
Appearance {
|
Appearance {
|
||||||
flavor: match record.flavor {
|
flavor: match record.flavor {
|
||||||
@@ -2403,6 +2577,12 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
|||||||
AccentChoice::Red => Accent::Red,
|
AccentChoice::Red => Accent::Red,
|
||||||
},
|
},
|
||||||
gradients: record.gradients,
|
gradients: record.gradients,
|
||||||
|
names: match record.names {
|
||||||
|
TitleNamesChoice::Library => TitleNames::Library,
|
||||||
|
TitleNamesChoice::English => TitleNames::English,
|
||||||
|
TitleNamesChoice::Romaji => TitleNames::Romaji,
|
||||||
|
},
|
||||||
|
..Appearance::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2476,6 +2656,7 @@ fn title_record(
|
|||||||
guide: Option<&EpisodeGuide>,
|
guide: Option<&EpisodeGuide>,
|
||||||
) -> TitleRecord {
|
) -> TitleRecord {
|
||||||
let metadata = record.and_then(|record| record.metadata.as_ref());
|
let metadata = record.and_then(|record| record.metadata.as_ref());
|
||||||
|
let details = metadata.and_then(|found| found.details.as_ref());
|
||||||
TitleRecord {
|
TitleRecord {
|
||||||
key: title.key.clone(),
|
key: title.key.clone(),
|
||||||
name: title.name.clone(),
|
name: title.name.clone(),
|
||||||
@@ -2500,6 +2681,17 @@ fn title_record(
|
|||||||
provider_episode_count: metadata.and_then(|metadata| metadata.episodes),
|
provider_episode_count: metadata.and_then(|metadata| metadata.episodes),
|
||||||
external_url: metadata.and_then(|metadata| metadata.url.clone()),
|
external_url: metadata.and_then(|metadata| metadata.url.clone()),
|
||||||
manual_match: record.is_some_and(|record| record.manual),
|
manual_match: record.is_some_and(|record| record.manual),
|
||||||
|
display_name: metadata.map_or_else(|| title.name.clone(), |found| found.name.clone()),
|
||||||
|
wide_url: metadata.and_then(|found| found.wide_art().map(str::to_owned)),
|
||||||
|
format_label: details.and_then(|details| details.format_label().map(str::to_owned)),
|
||||||
|
season_label: details.and_then(|details| details.season_label()),
|
||||||
|
studios: details.map_or_else(Vec::new, |details| details.studios.clone()),
|
||||||
|
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
|
||||||
|
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
|
||||||
|
airing: details.is_some_and(|details| details.is_airing()),
|
||||||
|
next_episode: None,
|
||||||
|
next_airing_at: None,
|
||||||
|
franchise: Vec::new(),
|
||||||
seasons: title
|
seasons: title
|
||||||
.seasons
|
.seasons
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2559,6 +2751,10 @@ fn match_record(metadata: TitleMetadata) -> MatchRecord {
|
|||||||
genres: metadata.genres,
|
genres: metadata.genres,
|
||||||
episode_count: metadata.episodes,
|
episode_count: metadata.episodes,
|
||||||
external_url: metadata.url,
|
external_url: metadata.url,
|
||||||
|
details: metadata
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|details| serde_json::to_string(details).ok()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2577,6 +2773,10 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
|
|||||||
genres: record.genres,
|
genres: record.genres,
|
||||||
episodes: record.episode_count,
|
episodes: record.episode_count,
|
||||||
url: record.external_url,
|
url: record.external_url,
|
||||||
|
details: record
|
||||||
|
.details
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|details| serde_json::from_str(details).ok()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2640,6 +2840,7 @@ mod tests {
|
|||||||
flavor,
|
flavor,
|
||||||
accent: AccentChoice::Mauve,
|
accent: AccentChoice::Mauve,
|
||||||
gradients: true,
|
gradients: true,
|
||||||
|
names: super::TitleNamesChoice::English,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2952,6 +3153,11 @@ mod tests {
|
|||||||
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
|
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
|
||||||
episodes: Some(26),
|
episodes: Some(26),
|
||||||
url: Some("https://anilist.co/anime/1".to_owned()),
|
url: Some("https://anilist.co/anime/1".to_owned()),
|
||||||
|
details: Some(pstr_core::metadata::TitleDetails {
|
||||||
|
studios: vec!["Sunrise".to_owned()],
|
||||||
|
related: vec!["5".to_owned()],
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(title_metadata(match_record(metadata.clone())), metadata);
|
assert_eq!(title_metadata(match_record(metadata.clone())), metadata);
|
||||||
@@ -2989,6 +3195,117 @@ mod tests {
|
|||||||
assert_eq!(pstr_android_stream_size(handle), -1);
|
assert_eq!(pstr_android_stream_size(handle), -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The arrangement folds a franchise into its series, and the name
|
||||||
|
/// setting — stored in the file the desktop reads — renames the tile
|
||||||
|
/// without anything in the catalog changing.
|
||||||
|
#[test]
|
||||||
|
fn the_library_folds_a_franchise_and_follows_the_name_setting() {
|
||||||
|
use pstr_core::catalog::CatalogNode;
|
||||||
|
use pstr_core::metadata::{MetadataRecord, TitleDetails};
|
||||||
|
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"pstr-android-arrangement-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
super::now()
|
||||||
|
));
|
||||||
|
let engine = AndroidEngine::new(
|
||||||
|
AndroidPaths {
|
||||||
|
config: root.join("config").to_string_lossy().into_owned(),
|
||||||
|
data: root.join("data").to_string_lossy().into_owned(),
|
||||||
|
cache: root.join("cache").to_string_lossy().into_owned(),
|
||||||
|
},
|
||||||
|
Box::<MemorySecrets>::default(),
|
||||||
|
)
|
||||||
|
.expect("engine");
|
||||||
|
|
||||||
|
let file = |link: &str, name: &str| CatalogNode {
|
||||||
|
share_id: "s".into(),
|
||||||
|
link_id: link.into(),
|
||||||
|
volume_id: "v".into(),
|
||||||
|
parent_link_id: None,
|
||||||
|
name: name.into(),
|
||||||
|
is_folder: false,
|
||||||
|
media_type: None,
|
||||||
|
size: None,
|
||||||
|
active_revision_id: None,
|
||||||
|
parsed: pstr_core::naming::parse(name),
|
||||||
|
};
|
||||||
|
let matched = |key: &str, id: &str, name: &str, kind, related: &str| MetadataRecord {
|
||||||
|
title_key: key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: None,
|
||||||
|
kind,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: None,
|
||||||
|
rating: None,
|
||||||
|
genres: Vec::new(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: Some(TitleDetails {
|
||||||
|
related: vec![related.into()],
|
||||||
|
..TitleDetails::default()
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: false,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut catalog = engine.catalog.lock();
|
||||||
|
catalog
|
||||||
|
.replace_share(
|
||||||
|
"s",
|
||||||
|
&[
|
||||||
|
file("a", "Sousou no Frieren S01E01.mkv"),
|
||||||
|
file("b", "Made in Abyss Dawn of the Deep Soul.mkv"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("crawl");
|
||||||
|
catalog
|
||||||
|
.set_metadata(&matched(
|
||||||
|
"sousou no frieren",
|
||||||
|
"1",
|
||||||
|
"Frieren",
|
||||||
|
TitleKind::Series,
|
||||||
|
"2",
|
||||||
|
))
|
||||||
|
.expect("match");
|
||||||
|
catalog
|
||||||
|
.set_metadata(&matched(
|
||||||
|
"made in abyss dawn of the deep soul",
|
||||||
|
"2",
|
||||||
|
"Dawn of the Deep Soul",
|
||||||
|
TitleKind::Film,
|
||||||
|
"1",
|
||||||
|
))
|
||||||
|
.expect("match");
|
||||||
|
}
|
||||||
|
|
||||||
|
let arranged = engine
|
||||||
|
.arrangement(None, super::LibrarySort::Name, true)
|
||||||
|
.expect("arrange");
|
||||||
|
assert_eq!(arranged.tiles.len(), 1);
|
||||||
|
assert_eq!(arranged.tiles[0].key, "sousou no frieren");
|
||||||
|
assert_eq!(arranged.tiles[0].folded, 1);
|
||||||
|
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||||
|
assert_eq!(titles.len(), 1);
|
||||||
|
assert_eq!(titles[0].display_name, "Frieren");
|
||||||
|
assert_eq!(titles[0].franchise.len(), 2);
|
||||||
|
|
||||||
|
let mut appearance = engine.appearance().expect("appearance");
|
||||||
|
appearance.names = super::TitleNamesChoice::Library;
|
||||||
|
engine.set_appearance(appearance).expect("store");
|
||||||
|
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||||
|
assert_eq!(titles[0].display_name, "Sousou no Frieren");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn share_mutations_cleanup_even_when_secret_deletion_fails() {
|
fn share_mutations_cleanup_even_when_secret_deletion_fails() {
|
||||||
let unique = format!(
|
let unique = format!(
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 13 KiB |
@@ -12,7 +12,7 @@ use pstr_core::Share;
|
|||||||
use pstr_core::appearance::Appearance;
|
use pstr_core::appearance::Appearance;
|
||||||
use pstr_core::config::AppDirs;
|
use pstr_core::config::AppDirs;
|
||||||
use pstr_core::library::Library;
|
use pstr_core::library::Library;
|
||||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
|
||||||
|
|
||||||
use crate::engine::{
|
use crate::engine::{
|
||||||
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
|
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
|
||||||
@@ -213,6 +213,8 @@ pub enum Action {
|
|||||||
FocusSearch,
|
FocusSearch,
|
||||||
/// Show a different slice of the library, or in a different order.
|
/// Show a different slice of the library, or in a different order.
|
||||||
SetShelf(Filter, Sort),
|
SetShelf(Filter, Sort),
|
||||||
|
/// Fold each franchise into one tile, or show every title.
|
||||||
|
SetGrouped(bool),
|
||||||
/// Open or close the list of keyboard shortcuts.
|
/// Open or close the list of keyboard shortcuts.
|
||||||
ToggleShortcuts,
|
ToggleShortcuts,
|
||||||
/// One page back: a title to the library, the library to nowhere.
|
/// One page back: a title to the library, the library to nowhere.
|
||||||
@@ -302,25 +304,55 @@ impl Matcher {
|
|||||||
/// every title by when it was last played. Neither is slow once; both were
|
/// every title by when it was last played. Neither is slow once; both were
|
||||||
/// being done on every frame, and while a film plays under the transport bar
|
/// being done on every frame, and while a film plays under the transport bar
|
||||||
/// that is the film's frame rate.
|
/// that is the film's frame rate.
|
||||||
#[derive(Default)]
|
|
||||||
pub struct LibraryView {
|
pub struct LibraryView {
|
||||||
/// The query `matches` answers, as typed.
|
/// The query `matches` answers, as typed.
|
||||||
query: String,
|
query: String,
|
||||||
/// Which titles the grid shows, and in what order.
|
/// Which titles the grid shows, and in what order.
|
||||||
pub filter: Filter,
|
pub filter: Filter,
|
||||||
pub sort: Sort,
|
pub sort: Sort,
|
||||||
/// The filter and order `matches` was built with.
|
/// One tile per franchise rather than one per title. See
|
||||||
built_with: (Filter, Sort),
|
/// `pstr_core::franchise`.
|
||||||
|
pub grouped: bool,
|
||||||
|
/// What `matches` was built with.
|
||||||
|
built_with: Option<(Filter, Sort, bool, TitleNames)>,
|
||||||
/// Indices into [`Library::titles`].
|
/// Indices into [`Library::titles`].
|
||||||
pub matches: Vec<usize>,
|
pub matches: Vec<usize>,
|
||||||
|
/// In a grouped grid, how many other titles a tile stands for, by index.
|
||||||
|
pub folded: HashMap<usize, usize>,
|
||||||
|
/// Every title in a franchise of more than one, by key: the keys of the
|
||||||
|
/// whole franchise in release order, its own included.
|
||||||
|
pub franchises: HashMap<String, Vec<String>>,
|
||||||
|
/// The rows above the grid, as a heading and indices.
|
||||||
|
pub shelves: Vec<(String, Vec<usize>)>,
|
||||||
/// Every played episode, newest first, for the history page.
|
/// Every played episode, newest first, for the history page.
|
||||||
pub history: Vec<ui::history::Entry>,
|
pub history: Vec<ui::history::Entry>,
|
||||||
/// The "Continue watching" shelf, most recent first, as indices.
|
/// The "Continue watching" shelf, most recent first, as indices.
|
||||||
pub resumable: Vec<usize>,
|
pub resumable: Vec<usize>,
|
||||||
/// The library changed under the lists.
|
/// The library or what is known about it changed under the lists.
|
||||||
stale: bool,
|
stale: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for LibraryView {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
query: String::new(),
|
||||||
|
filter: Filter::default(),
|
||||||
|
sort: Sort::default(),
|
||||||
|
// On: a share holding a series and its three films is four tiles
|
||||||
|
// of the same picture otherwise.
|
||||||
|
grouped: true,
|
||||||
|
built_with: None,
|
||||||
|
matches: Vec::new(),
|
||||||
|
folded: HashMap::new(),
|
||||||
|
franchises: HashMap::new(),
|
||||||
|
shelves: Vec::new(),
|
||||||
|
history: Vec::new(),
|
||||||
|
resumable: Vec::new(),
|
||||||
|
stale: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which titles the grid shows.
|
/// Which titles the grid shows.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum Filter {
|
pub enum Filter {
|
||||||
@@ -350,43 +382,95 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What order the grid is in.
|
pub use pstr_core::browse::Sort;
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum Sort {
|
/// What the library view is built from, besides the library itself.
|
||||||
/// The library's own order: by name, ignoring a leading article.
|
pub struct ViewInputs<'a> {
|
||||||
#[default]
|
pub query: &'a str,
|
||||||
Name,
|
pub metadata: &'a HashMap<String, MetadataRecord>,
|
||||||
/// Most recently played first.
|
pub names: TitleNames,
|
||||||
Recent,
|
/// When each title last gained a file, by key.
|
||||||
/// Newest first, undated last.
|
pub added: &'a HashMap<String, i64>,
|
||||||
Year,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many shelves the library shows above the grid.
|
||||||
|
const SHELVES: usize = 4;
|
||||||
|
|
||||||
impl LibraryView {
|
impl LibraryView {
|
||||||
fn refresh(&mut self, library: &Library, query: &str) {
|
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
|
||||||
if !self.stale && self.query == query && self.built_with == (self.filter, self.sort) {
|
use pstr_core::library::Title;
|
||||||
|
|
||||||
|
let built_with = (self.filter, self.sort, self.grouped, inputs.names);
|
||||||
|
if !self.stale && self.query == inputs.query && self.built_with == Some(built_with) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let index_of = |title: &pstr_core::library::Title| {
|
let metadata = inputs.metadata;
|
||||||
|
if self.stale {
|
||||||
|
let all: Vec<&Title> = library.titles.iter().collect();
|
||||||
|
self.franchises = pstr_core::franchise::group(&all, metadata)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|franchise| franchise.members.len() > 1)
|
||||||
|
.flat_map(|franchise| {
|
||||||
|
let keys: Vec<String> = franchise
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|&index| all[index].key.clone())
|
||||||
|
.collect();
|
||||||
|
keys.clone().into_iter().map(move |key| (key, keys.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let index: HashMap<&str, usize> = library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(at, title)| (title.key.as_str(), at))
|
||||||
|
.collect();
|
||||||
|
self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES)
|
||||||
|
.into_iter()
|
||||||
|
.map(|shelf| {
|
||||||
|
let indices = shelf
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|key| index.get(key.as_str()).copied())
|
||||||
|
.collect();
|
||||||
|
(shelf.label, indices)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|title| self.filter.admits(title))
|
||||||
|
.collect();
|
||||||
|
let tiles = pstr_core::browse::arrange(
|
||||||
|
&titles,
|
||||||
|
pstr_core::browse::Order {
|
||||||
|
sort: self.sort,
|
||||||
|
grouped: self.grouped,
|
||||||
|
names: inputs.names,
|
||||||
|
metadata,
|
||||||
|
added: inputs.added,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let index_of = |title: &Title| {
|
||||||
library
|
library
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
.position(|candidate| std::ptr::eq(candidate, title))
|
.position(|candidate| std::ptr::eq(candidate, title))
|
||||||
};
|
};
|
||||||
let mut matches: Vec<&pstr_core::library::Title> = library
|
// Indices into what was arranged, made indices into the library.
|
||||||
.search(query)
|
let entries: Vec<(usize, usize)> = tiles
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter(|title| self.filter.admits(title))
|
.filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len())))
|
||||||
.collect();
|
.collect();
|
||||||
match self.sort {
|
|
||||||
Sort::Name => {}
|
self.folded = entries
|
||||||
// Stable, so titles never played keep their alphabetical order
|
.iter()
|
||||||
// after the ones that have been.
|
.filter(|(_, members)| *members > 1)
|
||||||
Sort::Recent => matches.sort_by_key(|title| std::cmp::Reverse(title.last_played())),
|
.map(|(lead, members)| (*lead, members - 1))
|
||||||
Sort::Year => matches.sort_by_key(|title| std::cmp::Reverse(title.year.unwrap_or(0))),
|
.collect();
|
||||||
}
|
self.matches = entries.into_iter().map(|(lead, _)| lead).collect();
|
||||||
self.matches = matches.into_iter().filter_map(index_of).collect();
|
self.built_with = Some(built_with);
|
||||||
self.built_with = (self.filter, self.sort);
|
|
||||||
if self.stale {
|
if self.stale {
|
||||||
self.history = ui::history::entries(library);
|
self.history = ui::history::entries(library);
|
||||||
self.resumable = library
|
self.resumable = library
|
||||||
@@ -395,7 +479,7 @@ impl LibraryView {
|
|||||||
.filter_map(index_of)
|
.filter_map(index_of)
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
self.query = query.to_owned();
|
self.query = inputs.query.to_owned();
|
||||||
self.stale = false;
|
self.stale = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,6 +512,8 @@ pub struct App {
|
|||||||
pub posters: ImageCache,
|
pub posters: ImageCache,
|
||||||
/// What the providers have said, keyed by title key.
|
/// What the providers have said, keyed by title key.
|
||||||
pub metadata: HashMap<String, MetadataRecord>,
|
pub metadata: HashMap<String, MetadataRecord>,
|
||||||
|
/// When each title last gained a file, by key — for "Recently added".
|
||||||
|
pub added: HashMap<String, i64>,
|
||||||
/// What they said about the episodes under those titles.
|
/// What they said about the episodes under those titles.
|
||||||
pub episodes: HashMap<String, EpisodeGuide>,
|
pub episodes: HashMap<String, EpisodeGuide>,
|
||||||
pub settings: MetadataConfig,
|
pub settings: MetadataConfig,
|
||||||
@@ -531,6 +617,7 @@ impl App {
|
|||||||
thumbs: ImageCache::default(),
|
thumbs: ImageCache::default(),
|
||||||
posters: ImageCache::default(),
|
posters: ImageCache::default(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
|
added: HashMap::new(),
|
||||||
episodes: HashMap::new(),
|
episodes: HashMap::new(),
|
||||||
settings,
|
settings,
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
@@ -612,8 +699,9 @@ impl App {
|
|||||||
self.connecting = false;
|
self.connecting = false;
|
||||||
self.note(ctx, format!("could not open the shares: {error}"), true);
|
self.note(ctx, format!("could not open the shares: {error}"), true);
|
||||||
}
|
}
|
||||||
Event::LibraryLoaded(library) => {
|
Event::LibraryLoaded(library, added) => {
|
||||||
self.library = library;
|
self.library = library;
|
||||||
|
self.added = added;
|
||||||
self.loaded = true;
|
self.loaded = true;
|
||||||
self.view.stale = true;
|
self.view.stale = true;
|
||||||
}
|
}
|
||||||
@@ -678,6 +766,8 @@ impl App {
|
|||||||
self.posters.forget(&key);
|
self.posters.forget(&key);
|
||||||
}
|
}
|
||||||
self.metadata = records;
|
self.metadata = records;
|
||||||
|
// Names, franchises and shelves all come from it.
|
||||||
|
self.view.stale = true;
|
||||||
}
|
}
|
||||||
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
|
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
|
||||||
Event::MetadataConfig(config) => {
|
Event::MetadataConfig(config) => {
|
||||||
@@ -975,6 +1065,7 @@ impl App {
|
|||||||
posters: &mut self.posters,
|
posters: &mut self.posters,
|
||||||
metadata: &self.metadata,
|
metadata: &self.metadata,
|
||||||
episodes: &self.episodes,
|
episodes: &self.episodes,
|
||||||
|
names: self.engine.appearance().names,
|
||||||
}
|
}
|
||||||
.still(&title.key, episode)
|
.still(&title.key, episode)
|
||||||
});
|
});
|
||||||
@@ -1220,6 +1311,7 @@ impl App {
|
|||||||
self.view.filter = filter;
|
self.view.filter = filter;
|
||||||
self.view.sort = sort;
|
self.view.sort = sort;
|
||||||
}
|
}
|
||||||
|
Action::SetGrouped(grouped) => self.view.grouped = grouped,
|
||||||
Action::Back => match self.page {
|
Action::Back => match self.page {
|
||||||
Page::Title(_) => self.page = Page::Library,
|
Page::Title(_) => self.page = Page::Library,
|
||||||
// Back out of a search before out of anything else: it is the
|
// Back out of a search before out of anything else: it is the
|
||||||
@@ -1652,6 +1744,7 @@ impl eframe::App for App {
|
|||||||
posters,
|
posters,
|
||||||
metadata,
|
metadata,
|
||||||
episodes,
|
episodes,
|
||||||
|
names: engine.appearance().names,
|
||||||
},
|
},
|
||||||
open: *episode_drawer,
|
open: *episode_drawer,
|
||||||
});
|
});
|
||||||
@@ -1750,7 +1843,15 @@ impl eframe::App for App {
|
|||||||
});
|
});
|
||||||
self.search = search;
|
self.search = search;
|
||||||
self.focus_search = false;
|
self.focus_search = false;
|
||||||
self.view.refresh(&self.library, &self.search);
|
self.view.refresh(
|
||||||
|
&self.library,
|
||||||
|
ViewInputs {
|
||||||
|
query: &self.search,
|
||||||
|
metadata: &self.metadata,
|
||||||
|
names: self.engine.appearance().names,
|
||||||
|
added: &self.added,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let App {
|
let App {
|
||||||
engine,
|
engine,
|
||||||
@@ -1836,6 +1937,7 @@ impl eframe::App for App {
|
|||||||
posters: &mut *posters,
|
posters: &mut *posters,
|
||||||
metadata,
|
metadata,
|
||||||
episodes,
|
episodes,
|
||||||
|
names: engine.appearance().names,
|
||||||
};
|
};
|
||||||
match page {
|
match page {
|
||||||
Page::Library => ui::library::show(
|
Page::Library => ui::library::show(
|
||||||
@@ -1846,6 +1948,7 @@ impl eframe::App for App {
|
|||||||
view,
|
view,
|
||||||
loaded: *loaded,
|
loaded: *loaded,
|
||||||
search,
|
search,
|
||||||
|
tiles: engine.appearance().tiles,
|
||||||
},
|
},
|
||||||
&mut actions,
|
&mut actions,
|
||||||
),
|
),
|
||||||
@@ -1854,6 +1957,7 @@ impl eframe::App for App {
|
|||||||
&mut art,
|
&mut art,
|
||||||
library,
|
library,
|
||||||
key,
|
key,
|
||||||
|
view.franchises.get(key.as_str()).map(Vec::as_slice),
|
||||||
ui::title::OfflineView {
|
ui::title::OfflineView {
|
||||||
downloads,
|
downloads,
|
||||||
files: offline_files,
|
files: offline_files,
|
||||||
@@ -1883,6 +1987,7 @@ impl eframe::App for App {
|
|||||||
posters,
|
posters,
|
||||||
metadata,
|
metadata,
|
||||||
episodes,
|
episodes,
|
||||||
|
names: engine.appearance().names,
|
||||||
};
|
};
|
||||||
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
|
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
|
||||||
}
|
}
|
||||||
@@ -2036,7 +2141,8 @@ fn crawl_label(progress: &std::collections::BTreeMap<String, usize>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Titles whose tile art is not what it was.
|
/// Texture keys whose picture is not what it was: a title's tile art, its cover
|
||||||
|
/// and its wide art each.
|
||||||
///
|
///
|
||||||
/// Compared by URL: the same URL is the same picture, and anything else —
|
/// Compared by URL: the same URL is the same picture, and anything else —
|
||||||
/// a different one, a new one, or none any more — has to be fetched or
|
/// a different one, a new one, or none any more — has to be fetched or
|
||||||
@@ -2045,19 +2151,27 @@ fn changed_art(
|
|||||||
before: &HashMap<String, MetadataRecord>,
|
before: &HashMap<String, MetadataRecord>,
|
||||||
after: &HashMap<String, MetadataRecord>,
|
after: &HashMap<String, MetadataRecord>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let url = |records: &HashMap<String, MetadataRecord>, key: &str| {
|
// Every picture a title is drawn with, in the order of the keys below.
|
||||||
records
|
let urls = |records: &HashMap<String, MetadataRecord>, key: &str| {
|
||||||
.get(key)
|
let found = records.get(key).and_then(|record| record.metadata.as_ref());
|
||||||
.and_then(|record| record.metadata.as_ref())
|
[
|
||||||
.and_then(|metadata| metadata.tile_art())
|
found
|
||||||
.map(|(url, _)| url.to_owned())
|
.and_then(|metadata| metadata.tile_art())
|
||||||
|
.map(|(url, _)| url.to_owned()),
|
||||||
|
found.and_then(|metadata| metadata.poster_url.clone()),
|
||||||
|
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
|
||||||
|
]
|
||||||
};
|
};
|
||||||
let mut keys: Vec<String> = before
|
let mut keys: Vec<String> = Vec::new();
|
||||||
.keys()
|
for key in before.keys().chain(after.keys()) {
|
||||||
.chain(after.keys())
|
let (old, new) = (urls(before, key), urls(after, key));
|
||||||
.filter(|key| url(before, key) != url(after, key))
|
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
|
||||||
.cloned()
|
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
|
||||||
.collect();
|
if old != new {
|
||||||
|
keys.push(texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
keys.sort_unstable();
|
keys.sort_unstable();
|
||||||
keys.dedup();
|
keys.dedup();
|
||||||
keys
|
keys
|
||||||
@@ -2306,6 +2420,7 @@ mod tests {
|
|||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
url: None,
|
url: None,
|
||||||
|
details: None,
|
||||||
});
|
});
|
||||||
(
|
(
|
||||||
key.into(),
|
key.into(),
|
||||||
@@ -2395,9 +2510,20 @@ mod tests {
|
|||||||
record("added", Some("w")),
|
record("added", Some("w")),
|
||||||
]
|
]
|
||||||
.into();
|
.into();
|
||||||
|
// The tile and the cover are both drawn from the poster here; the
|
||||||
|
// wide art has nothing to be.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
changed_art(&before, &after),
|
changed_art(&before, &after),
|
||||||
vec!["added", "dropped", "found", "moved"]
|
vec![
|
||||||
|
"added",
|
||||||
|
"dropped",
|
||||||
|
"found",
|
||||||
|
"moved",
|
||||||
|
"poster:added",
|
||||||
|
"poster:dropped",
|
||||||
|
"poster:found",
|
||||||
|
"poster:moved"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +160,11 @@ struct PartialMarker {
|
|||||||
/// difference is VRAM held for every title in the library.
|
/// difference is VRAM held for every title in the library.
|
||||||
const THUMBNAIL_MAX_EDGE: u32 = 640;
|
const THUMBNAIL_MAX_EDGE: u32 = 640;
|
||||||
|
|
||||||
|
/// The longest edge of a picture drawn across the whole window. 1920 is what
|
||||||
|
/// a provider's fanart is, so nothing is scaled up and a 4K screen gets the
|
||||||
|
/// best there is.
|
||||||
|
pub const WIDE_MAX_EDGE: u32 = 1920;
|
||||||
|
|
||||||
/// What the background side tells the UI.
|
/// What the background side tells the UI.
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
/// The share list changed on disk.
|
/// The share list changed on disk.
|
||||||
@@ -175,8 +180,9 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
/// Shares could not be opened at all.
|
/// Shares could not be opened at all.
|
||||||
ConnectFailed(String),
|
ConnectFailed(String),
|
||||||
/// The catalog was re-read.
|
/// The catalog was re-read: the library, and when each title last
|
||||||
LibraryLoaded(Library),
|
/// gained a file.
|
||||||
|
LibraryLoaded(Library, HashMap<String, i64>),
|
||||||
/// A crawl finished for one share.
|
/// A crawl finished for one share.
|
||||||
Crawled {
|
Crawled {
|
||||||
share_id: String,
|
share_id: String,
|
||||||
@@ -739,15 +745,16 @@ impl Engine {
|
|||||||
self.runtime.spawn_blocking(move || {
|
self.runtime.spawn_blocking(move || {
|
||||||
let result = {
|
let result = {
|
||||||
let catalog = engine.catalog.lock();
|
let catalog = engine.catalog.lock();
|
||||||
catalog
|
catalog.all_files().and_then(|files| {
|
||||||
.all_files()
|
Ok((files, catalog.all_watch_states()?, catalog.first_seen()?))
|
||||||
.and_then(|files| Ok((files, catalog.all_watch_states()?)))
|
})
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok((files, watch)) => {
|
Ok((files, watch, first_seen)) => {
|
||||||
let library = Library::build(files, &watch);
|
let library = Library::build(files, &watch);
|
||||||
let targets = download_targets(&library);
|
let targets = download_targets(&library);
|
||||||
engine.emit(Event::LibraryLoaded(library));
|
let added = library.added_at(&first_seen);
|
||||||
|
engine.emit(Event::LibraryLoaded(library, added));
|
||||||
engine.refresh_offline(Some(targets));
|
engine.refresh_offline(Some(targets));
|
||||||
}
|
}
|
||||||
Err(error) => engine.fail("read the catalog", error),
|
Err(error) => engine.fail("read the catalog", error),
|
||||||
@@ -1903,44 +1910,25 @@ impl Engine {
|
|||||||
};
|
};
|
||||||
let provider = service.provider();
|
let provider = service.provider();
|
||||||
|
|
||||||
let (stored, listed) = {
|
let (stored, enriched) = {
|
||||||
let catalog = self.catalog.lock();
|
let catalog = self.catalog.lock();
|
||||||
match catalog
|
match catalog
|
||||||
.all_metadata()
|
.all_metadata()
|
||||||
.and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?)))
|
.and_then(|stored| Ok((stored, catalog.enrichment_ages()?)))
|
||||||
{
|
{
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(error) => return self.fail("read stored metadata", error),
|
Err(error) => return self.fail("read stored metadata", error),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Two kinds of work, and the difference is a request saved: a title
|
let pending = pstr_meta::service::plan(
|
||||||
// that already has a good match but no episode list needs the episode
|
titles,
|
||||||
// request only — searching for it again would ask the provider
|
&stored,
|
||||||
// something it has already answered.
|
&enriched,
|
||||||
let mut pending: Vec<Work> = Vec::new();
|
provider,
|
||||||
for title in titles {
|
service.has_details(),
|
||||||
let record = stored.get(&title.key);
|
force,
|
||||||
// A hand-picked entry is never searched for again, not even by
|
);
|
||||||
// "match again" — that button means "the automatic answers are
|
|
||||||
// wrong", and re-deciding the one title the viewer already fixed by
|
|
||||||
// hand is the opposite of what they asked for. Its episode list is
|
|
||||||
// still fetched below if it is missing.
|
|
||||||
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
|
|
||||||
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
|
|
||||||
pending.push(Work::Match(title));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let has_episodes = listed
|
|
||||||
.get(&title.key)
|
|
||||||
.is_some_and(|(asked, _)| *asked == provider);
|
|
||||||
if let Some(found) = record.and_then(|record| record.metadata.clone())
|
|
||||||
&& !has_episodes
|
|
||||||
{
|
|
||||||
pending.push(Work::Episodes(title, Box::new(found)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return self.emit(Event::Status("everything is already matched".into()));
|
return self.emit(Event::Status("everything is already matched".into()));
|
||||||
}
|
}
|
||||||
@@ -1959,25 +1947,7 @@ impl Engine {
|
|||||||
let Ok(_permit) = permits.acquire().await else {
|
let Ok(_permit) = permits.acquire().await else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
Some(match work {
|
Some(service.work(work).await)
|
||||||
Work::Match(title) => match service.record(title).await {
|
|
||||||
Ok(record) => {
|
|
||||||
// Only for a title that matched: there is no id to
|
|
||||||
// ask about otherwise.
|
|
||||||
let episodes = match &record.metadata {
|
|
||||||
Some(found) => service.title_episodes(title, found).await,
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
Ok((record.title_key.clone(), Some(record), episodes))
|
|
||||||
}
|
|
||||||
Err(error) => Err(error),
|
|
||||||
},
|
|
||||||
Work::Episodes(title, found) => Ok((
|
|
||||||
title.key.clone(),
|
|
||||||
None,
|
|
||||||
service.title_episodes(title, found).await,
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1985,8 +1955,8 @@ impl Engine {
|
|||||||
let mut episodes_found = 0usize;
|
let mut episodes_found = 0usize;
|
||||||
while let Some(result) = lookups.next().await {
|
while let Some(result) = lookups.next().await {
|
||||||
match result {
|
match result {
|
||||||
Some(Ok((title_key, record, episodes))) => {
|
Some(Ok(outcome)) => {
|
||||||
if let Some(record) = &record {
|
if let Some(record) = &outcome.record {
|
||||||
if record.metadata.is_some() {
|
if record.metadata.is_some() {
|
||||||
matched += 1;
|
matched += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -1998,16 +1968,16 @@ impl Engine {
|
|||||||
tracing::warn!("store metadata for {}: {error}", record.title_key);
|
tracing::warn!("store metadata for {}: {error}", record.title_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !episodes.is_empty() {
|
if let Some(enrichment) = &outcome.enrichment {
|
||||||
episodes_found += episodes.len();
|
episodes_found += enrichment.episodes.len();
|
||||||
let stored = self.catalog.lock().set_episode_metadata(
|
let stored = self.catalog.lock().set_enrichment(
|
||||||
&title_key,
|
&outcome.title_key,
|
||||||
provider,
|
provider,
|
||||||
now(),
|
now(),
|
||||||
&episodes,
|
enrichment,
|
||||||
);
|
);
|
||||||
if let Err(error) = stored {
|
if let Err(error) = stored {
|
||||||
tracing::warn!("store episodes for {title_key}: {error}");
|
tracing::warn!("store enrichment for {}: {error}", outcome.title_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2078,17 +2048,21 @@ impl Engine {
|
|||||||
return engine.fail("store the match", error);
|
return engine.fail("store the match", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let episodes = service.title_episodes(&title, &found).await;
|
// A failure leaves the title un-enriched, and the next match run
|
||||||
if !episodes.is_empty() {
|
// asks again.
|
||||||
let stored = engine.catalog.lock().set_episode_metadata(
|
match service.enrich(&title, &found).await {
|
||||||
&title.key,
|
Ok(enrichment) => {
|
||||||
service.provider(),
|
let stored = engine.catalog.lock().set_enrichment(
|
||||||
now(),
|
&title.key,
|
||||||
&episodes,
|
service.provider(),
|
||||||
);
|
now(),
|
||||||
if let Err(error) = stored {
|
&enrichment,
|
||||||
tracing::warn!("store episodes for {}: {error}", title.key);
|
);
|
||||||
|
if let Err(error) = stored {
|
||||||
|
tracing::warn!("store enrichment for {}: {error}", title.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Err(error) => tracing::warn!("enrich {}: {error}", title.key),
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
|
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
|
||||||
@@ -2128,15 +2102,26 @@ impl Engine {
|
|||||||
/// paths invalidates by itself and the same picture shared by two titles is
|
/// paths invalidates by itself and the same picture shared by two titles is
|
||||||
/// fetched once.
|
/// fetched once.
|
||||||
pub fn request_poster(&self, title_key: String, url: String) {
|
pub fn request_poster(&self, title_key: String, url: String) {
|
||||||
|
self.request_art(title_key, url, THUMBNAIL_MAX_EDGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Engine::request_poster`], for a picture drawn across the window —
|
||||||
|
/// the library's hero, the title page's band — kept at up to `max_edge`
|
||||||
|
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
|
||||||
|
/// most of why those looked soft.
|
||||||
|
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
|
||||||
let engine = self.clone();
|
let engine = self.clone();
|
||||||
let path = self
|
// A size of its own on disk: the tile-sized copy of the same URL is
|
||||||
.dirs
|
// already there under the plain name, scaled down.
|
||||||
.poster_cache()
|
let name = match max_edge {
|
||||||
.join(format!("{}.img", digest(&url)));
|
THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)),
|
||||||
|
edge => format!("{}-{edge}.img", digest(&url)),
|
||||||
|
};
|
||||||
|
let path = self.dirs.poster_cache().join(name);
|
||||||
|
|
||||||
self.runtime.spawn(async move {
|
self.runtime.spawn(async move {
|
||||||
if let Ok(bytes) = tokio::fs::read(&path).await {
|
if let Ok(bytes) = tokio::fs::read(&path).await {
|
||||||
match decode_thumbnail(&bytes) {
|
match decode_scaled(&bytes, max_edge) {
|
||||||
Ok(decoded) => {
|
Ok(decoded) => {
|
||||||
if let Some(smaller) = &decoded.smaller {
|
if let Some(smaller) = &decoded.smaller {
|
||||||
store_image(&path, smaller).await;
|
store_image(&path, smaller).await;
|
||||||
@@ -2163,7 +2148,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match decode_thumbnail(&bytes) {
|
match decode_scaled(&bytes, max_edge) {
|
||||||
Ok(decoded) => {
|
Ok(decoded) => {
|
||||||
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
|
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
|
||||||
engine.emit(Event::Poster {
|
engine.emit(Event::Poster {
|
||||||
@@ -2202,15 +2187,6 @@ fn digest(url: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cache and texture key for a file's poster.
|
|
||||||
/// One title's worth of work in a match run.
|
|
||||||
enum Work {
|
|
||||||
/// Search for it, and take its episodes if it matches.
|
|
||||||
Match(Title),
|
|
||||||
/// It is already matched; only the episode list is missing.
|
|
||||||
Episodes(Title, Box<pstr_core::metadata::TitleMetadata>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unix seconds, for the "when was this asked" columns.
|
/// Unix seconds, for the "when was this asked" columns.
|
||||||
fn now() -> i64 {
|
fn now() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
@@ -2348,9 +2324,14 @@ struct Decoded {
|
|||||||
/// copy is handed back so the cache holds that instead, and the next launch
|
/// copy is handed back so the cache holds that instead, and the next launch
|
||||||
/// decodes a picture already the size it is drawn at.
|
/// decodes a picture already the size it is drawn at.
|
||||||
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
|
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
|
||||||
|
decode_scaled(bytes, THUMBNAIL_MAX_EDGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
|
||||||
|
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
|
||||||
let decoded = image::load_from_memory(bytes)?;
|
let decoded = image::load_from_memory(bytes)?;
|
||||||
let (decoded, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE {
|
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
|
||||||
let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE);
|
let scaled = decoded.thumbnail(max_edge, max_edge);
|
||||||
let encoded = encode_for_cache(&scaled);
|
let encoded = encode_for_cache(&scaled);
|
||||||
(scaled, encoded)
|
(scaled, encoded)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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,8 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +27,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
|||||||
view,
|
view,
|
||||||
loaded,
|
loaded,
|
||||||
search,
|
search,
|
||||||
|
tiles,
|
||||||
} = shelves;
|
} = shelves;
|
||||||
if !loaded {
|
if !loaded {
|
||||||
return placeholder(ui);
|
return placeholder(ui);
|
||||||
@@ -55,6 +59,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 +100,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 +135,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 +158,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 +266,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 +280,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,6 +302,19 @@ 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;
|
||||||
|
if ui
|
||||||
|
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
|
||||||
|
.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(
|
if let Some(filter) = ui::widgets::segmented(
|
||||||
ui,
|
ui,
|
||||||
@@ -308,6 +331,57 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 part-watched title, scrolling sideways.
|
/// The top shelf: one card per part-watched title, scrolling sideways.
|
||||||
fn continue_row(
|
fn continue_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -337,17 +411,20 @@ fn continue_row(
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| episode.label());
|
.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 +438,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 +470,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);
|
||||||
@@ -517,7 +616,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| {
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ 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;
|
||||||
|
|
||||||
/// Everything the pages need to draw one title's picture.
|
/// Everything the pages need to draw one title's picture.
|
||||||
@@ -40,8 +40,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 +78,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 +160,17 @@ 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}")
|
||||||
|
}
|
||||||
|
|
||||||
/// `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 {
|
||||||
@@ -492,6 +559,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 +569,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,7 +585,10 @@ 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);
|
||||||
|
|
||||||
@@ -574,7 +647,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 +660,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 +858,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
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
//! 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 crate::app::Action;
|
use crate::app::Action;
|
||||||
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||||
@@ -102,6 +102,41 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -369,7 +404,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);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,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 +42,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
|
||||||
@@ -100,6 +110,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>) {
|
||||||
@@ -190,16 +254,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 +281,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 +297,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 { "" }
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -449,12 +525,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 +616,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));
|
||||||
|
|||||||
@@ -483,18 +483,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 +505,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 +514,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");
|
||||||
@@ -926,3 +966,10 @@ async fn play(
|
|||||||
drop(opened.library);
|
drop(opened.library);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unix seconds, for the "when was this asked" columns.
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as i64)
|
||||||
|
}
|
||||||
@@ -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,395 @@
|
|||||||
|
//! 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::library::{Library, Title, sort_name};
|
||||||
|
use crate::metadata::{MetadataRecord, TitleNames};
|
||||||
|
|
||||||
|
/// What order a grid is in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,14 @@ use rusqlite::{Connection, OptionalExtension, params};
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::library::TitleKind;
|
use crate::library::TitleKind;
|
||||||
use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleMetadata};
|
use crate::metadata::{
|
||||||
|
Enrichment, EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleDetails,
|
||||||
|
TitleMetadata,
|
||||||
|
};
|
||||||
use crate::naming::{self, ParsedName};
|
use crate::naming::{self, ParsedName};
|
||||||
|
|
||||||
/// Bump when adding a migration.
|
/// Bump when adding a migration.
|
||||||
pub const SCHEMA_VERSION: i64 = 8;
|
pub const SCHEMA_VERSION: i64 = 10;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -153,6 +156,57 @@ ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT;
|
|||||||
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
|
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Episodes numbered three ways, and a record of which titles were enriched.
|
||||||
|
///
|
||||||
|
/// A release numbers its files by TVDB season, by absolute number inside a
|
||||||
|
/// season folder, or by AniList entry; an episode row now carries all three so
|
||||||
|
/// `EpisodeGuide::get` can meet whichever the filename used. `enriched_at` is
|
||||||
|
/// set once a matched title's episodes and art were asked for — empty answers
|
||||||
|
/// included, which is what stops a film being re-asked on every run.
|
||||||
|
///
|
||||||
|
/// The deletes are a repair, not a reset. Every AniList episode row came from
|
||||||
|
/// `streamingEpisodes` — partial, numbered however a streaming site numbered
|
||||||
|
/// it, and in places a later season captioned with season one's names — and
|
||||||
|
/// every AniList backdrop was a 4.75:1 banner drawn into a 16:9 tile. Both are
|
||||||
|
/// wrong rather than stale, so they go; the matches stay, hand-picked ones
|
||||||
|
/// included, and the next match run enriches them again. See
|
||||||
|
/// `docs/METADATA.md`.
|
||||||
|
const MIGRATION_V9: &str = r#"
|
||||||
|
ALTER TABLE episode_metadata ADD COLUMN absolute INTEGER;
|
||||||
|
ALTER TABLE episode_metadata ADD COLUMN entry_season INTEGER;
|
||||||
|
ALTER TABLE episode_metadata ADD COLUMN entry_number INTEGER;
|
||||||
|
ALTER TABLE title_metadata ADD COLUMN enriched_at INTEGER;
|
||||||
|
DELETE FROM episode_metadata WHERE provider = 'anilist';
|
||||||
|
UPDATE title_metadata SET backdrop_url = NULL WHERE provider = 'anilist';
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// What the library is sorted, grouped and shelved by, and when each file
|
||||||
|
/// first appeared.
|
||||||
|
///
|
||||||
|
/// `details` is [`TitleDetails`] as JSON — see there for why one column.
|
||||||
|
/// `NULL` means it was never fetched, which a match run reads as "fetch it"
|
||||||
|
/// rather than as "there is none"; every existing match starts that way.
|
||||||
|
/// `chain` is the provider ids enrichment walked, newline-separated; every
|
||||||
|
/// title is marked un-enriched so the next run records it.
|
||||||
|
///
|
||||||
|
/// `node_first_seen` is kept apart from `nodes` because a recrawl replaces
|
||||||
|
/// every node row and "recently added" has to survive that. Files already in
|
||||||
|
/// the catalog are recorded as seen at 0 — when they arrived is not known, and
|
||||||
|
/// pretending it was today would put the whole library at the top.
|
||||||
|
const MIGRATION_V10: &str = r#"
|
||||||
|
ALTER TABLE title_metadata ADD COLUMN details TEXT;
|
||||||
|
ALTER TABLE title_metadata ADD COLUMN chain TEXT;
|
||||||
|
CREATE TABLE node_first_seen (
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
link_id TEXT NOT NULL,
|
||||||
|
seen_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (share_id, link_id)
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
|
||||||
|
SELECT share_id, link_id, 0 FROM nodes WHERE is_folder = 0;
|
||||||
|
UPDATE title_metadata SET enriched_at = NULL;
|
||||||
|
"#;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct OfflineFile {
|
pub struct OfflineFile {
|
||||||
pub revision_id: String,
|
pub revision_id: String,
|
||||||
@@ -332,6 +386,18 @@ impl Catalog {
|
|||||||
tx.pragma_update(None, "user_version", 8)?;
|
tx.pragma_update(None, "user_version", 8)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if version < 9 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V9)?;
|
||||||
|
tx.pragma_update(None, "user_version", 9)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
if version < 10 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V10)?;
|
||||||
|
tx.pragma_update(None, "user_version", 10)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -392,6 +458,13 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only files: a folder appearing is not something anyone watches.
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at)
|
||||||
|
SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0",
|
||||||
|
params![share_id, unix_now()],
|
||||||
|
)?;
|
||||||
|
|
||||||
if !retain_offline {
|
if !retain_offline {
|
||||||
tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?;
|
tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?;
|
||||||
}
|
}
|
||||||
@@ -428,6 +501,18 @@ impl Catalog {
|
|||||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When each file first appeared in a crawl, keyed `(share_id, link_id)`.
|
||||||
|
/// `0` for a file that was already there before this was recorded.
|
||||||
|
pub fn first_seen(&self) -> Result<HashMap<(String, String), i64>> {
|
||||||
|
let mut statement = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT share_id, link_id, seen_at FROM node_first_seen")?;
|
||||||
|
let rows = statement.query_map([], |row| {
|
||||||
|
Ok(((row.get(0)?, row.get(1)?), row.get::<_, i64>(2)?))
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop everything a share contributed, watch positions included.
|
/// Drop everything a share contributed, watch positions included.
|
||||||
///
|
///
|
||||||
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
|
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
|
||||||
@@ -445,6 +530,10 @@ impl Catalog {
|
|||||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||||
params![share_id],
|
params![share_id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM node_first_seen WHERE share_id = ?1",
|
||||||
|
params![share_id],
|
||||||
|
)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -665,7 +754,7 @@ impl Catalog {
|
|||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
||||||
manual
|
manual, details, chain
|
||||||
FROM title_metadata",
|
FROM title_metadata",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], row_to_metadata)?;
|
let rows = statement.query_map([], row_to_metadata)?;
|
||||||
@@ -686,7 +775,7 @@ impl Catalog {
|
|||||||
.query_row(
|
.query_row(
|
||||||
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
|
||||||
url, manual
|
url, manual, details, chain
|
||||||
FROM title_metadata WHERE title_key = ?1",
|
FROM title_metadata WHERE title_key = ?1",
|
||||||
params![title_key],
|
params![title_key],
|
||||||
row_to_metadata,
|
row_to_metadata,
|
||||||
@@ -699,14 +788,41 @@ impl Catalog {
|
|||||||
///
|
///
|
||||||
/// A miss is stored too — see [`MetadataRecord::metadata`]. Storing only
|
/// A miss is stored too — see [`MetadataRecord::metadata`]. Storing only
|
||||||
/// matches would leave every unmatched title asking again on every render.
|
/// matches would leave every unmatched title asking again on every render.
|
||||||
|
///
|
||||||
|
/// Clears the title's enrichment record, so the answer is enriched again;
|
||||||
|
/// see [`Catalog::set_enrichment`]. And when the answer is a different
|
||||||
|
/// entry from the one stored — or none — the old entry's episode rows go
|
||||||
|
/// at once: they are not about this one, and a front end that pins a match
|
||||||
|
/// without enriching it must not show them in the meantime.
|
||||||
pub fn set_metadata(&self, record: &MetadataRecord) -> Result<()> {
|
pub fn set_metadata(&self, record: &MetadataRecord) -> Result<()> {
|
||||||
let data = record.metadata.as_ref();
|
let data = record.metadata.as_ref();
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO title_metadata (
|
"DELETE FROM episode_metadata WHERE title_key = ?1 AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM title_metadata
|
||||||
|
WHERE title_key = ?1 AND provider = ?2 AND remote_id IS ?3
|
||||||
|
)",
|
||||||
|
params![
|
||||||
|
record.title_key,
|
||||||
|
record.provider.as_str(),
|
||||||
|
data.map(|data| data.remote_id.as_str()),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let details = data
|
||||||
|
.and_then(|data| data.details.as_ref())
|
||||||
|
.map(serde_json::to_string)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|error| crate::error::Error::Config(error.to_string()))?;
|
||||||
|
// `same` below is whether the row already describes this entry. When
|
||||||
|
// it does, what enrichment added — the fanart, the chain, the record
|
||||||
|
// that it was asked — still describes it and is kept: refreshing a
|
||||||
|
// match's details must not cost the library its episode lists.
|
||||||
|
self.conn.execute(
|
||||||
|
&"INSERT INTO title_metadata (
|
||||||
title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
||||||
manual
|
manual, details
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
|
||||||
|
?17, ?18)
|
||||||
ON CONFLICT (title_key) DO UPDATE SET
|
ON CONFLICT (title_key) DO UPDATE SET
|
||||||
provider = excluded.provider,
|
provider = excluded.provider,
|
||||||
matched = excluded.matched,
|
matched = excluded.matched,
|
||||||
@@ -718,12 +834,23 @@ impl Catalog {
|
|||||||
year = excluded.year,
|
year = excluded.year,
|
||||||
kind = excluded.kind,
|
kind = excluded.kind,
|
||||||
poster_url = excluded.poster_url,
|
poster_url = excluded.poster_url,
|
||||||
backdrop_url = excluded.backdrop_url,
|
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
|
||||||
|
title_metadata.backdrop_url)
|
||||||
|
ELSE excluded.backdrop_url END,
|
||||||
rating = excluded.rating,
|
rating = excluded.rating,
|
||||||
genres = excluded.genres,
|
genres = excluded.genres,
|
||||||
episodes = excluded.episodes,
|
episodes = excluded.episodes,
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
manual = excluded.manual",
|
manual = excluded.manual,
|
||||||
|
details = excluded.details,
|
||||||
|
chain = CASE WHEN same THEN title_metadata.chain END,
|
||||||
|
enriched_at = CASE WHEN same THEN title_metadata.enriched_at END"
|
||||||
|
.replace(
|
||||||
|
"same",
|
||||||
|
"(title_metadata.provider = excluded.provider
|
||||||
|
AND title_metadata.remote_id IS excluded.remote_id
|
||||||
|
AND excluded.matched = 1)",
|
||||||
|
),
|
||||||
params![
|
params![
|
||||||
record.title_key,
|
record.title_key,
|
||||||
record.provider.as_str(),
|
record.provider.as_str(),
|
||||||
@@ -742,6 +869,7 @@ impl Catalog {
|
|||||||
data.and_then(|data| data.episodes),
|
data.and_then(|data| data.episodes),
|
||||||
data.and_then(|data| data.url.as_deref()),
|
data.and_then(|data| data.url.as_deref()),
|
||||||
record.manual as i64,
|
record.manual as i64,
|
||||||
|
details,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -776,12 +904,7 @@ impl Catalog {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace everything stored about one title's episodes.
|
/// Replace everything stored about one title's episodes, and nothing else.
|
||||||
///
|
|
||||||
/// A replace rather than an upsert per row: a provider that has *dropped*
|
|
||||||
/// an episode — a special that was recounted, a season renumbered — should
|
|
||||||
/// leave nothing behind, and an episode list is small enough that the
|
|
||||||
/// difference is a few hundred microseconds.
|
|
||||||
pub fn set_episode_metadata(
|
pub fn set_episode_metadata(
|
||||||
&mut self,
|
&mut self,
|
||||||
title_key: &str,
|
title_key: &str,
|
||||||
@@ -790,31 +913,40 @@ impl Catalog {
|
|||||||
episodes: &[EpisodeMetadata],
|
episodes: &[EpisodeMetadata],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
|
replace_episodes(&tx, title_key, provider, fetched_at, episodes)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store what enriching a matched title found, and that it was asked.
|
||||||
|
///
|
||||||
|
/// The episode list is replaced even when the new one is empty — a title
|
||||||
|
/// re-pinned from a series to its film must not keep the series' episode
|
||||||
|
/// names. The backdrop only changes when one was found. And the title is
|
||||||
|
/// marked enriched either way, so a film with no episodes is not asked
|
||||||
|
/// about again on every run; [`Catalog::set_metadata`] clears the mark when
|
||||||
|
/// the match itself changes.
|
||||||
|
pub fn set_enrichment(
|
||||||
|
&mut self,
|
||||||
|
title_key: &str,
|
||||||
|
provider: ProviderId,
|
||||||
|
fetched_at: i64,
|
||||||
|
enrichment: &Enrichment,
|
||||||
|
) -> Result<()> {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM episode_metadata WHERE title_key = ?1",
|
"UPDATE title_metadata
|
||||||
params![title_key],
|
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5
|
||||||
|
WHERE title_key = ?1 AND provider = ?2",
|
||||||
|
params![
|
||||||
|
title_key,
|
||||||
|
provider.as_str(),
|
||||||
|
fetched_at,
|
||||||
|
enrichment.backdrop_url.as_deref(),
|
||||||
|
enrichment.chain.join("\n"),
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
{
|
|
||||||
let mut insert = tx.prepare(
|
|
||||||
"INSERT INTO episode_metadata (
|
|
||||||
title_key, season, number, provider, fetched_at,
|
|
||||||
name, overview, still_url, air_date
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
||||||
)?;
|
|
||||||
for episode in episodes {
|
|
||||||
insert.execute(params![
|
|
||||||
title_key,
|
|
||||||
episode.season.map_or(ABSOLUTE_SEASON, i64::from),
|
|
||||||
episode.number,
|
|
||||||
provider.as_str(),
|
|
||||||
fetched_at,
|
|
||||||
episode.name.as_deref(),
|
|
||||||
episode.overview.as_deref(),
|
|
||||||
episode.still_url.as_deref(),
|
|
||||||
episode.air_date.as_deref(),
|
|
||||||
])?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -825,7 +957,8 @@ impl Catalog {
|
|||||||
/// season drawn a row at a time must not be a SQLite round-trip per row.
|
/// season drawn a row at a time must not be a SQLite round-trip per row.
|
||||||
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
|
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
|
||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, season, number, name, overview, still_url, air_date
|
"SELECT title_key, season, number, name, overview, still_url, air_date,
|
||||||
|
absolute, entry_season, entry_number
|
||||||
FROM episode_metadata",
|
FROM episode_metadata",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], |row| {
|
let rows = statement.query_map([], |row| {
|
||||||
@@ -836,6 +969,9 @@ impl Catalog {
|
|||||||
EpisodeMetadata {
|
EpisodeMetadata {
|
||||||
season: (season != ABSOLUTE_SEASON).then_some(season as u32),
|
season: (season != ABSOLUTE_SEASON).then_some(season as u32),
|
||||||
number: row.get::<_, i64>(2)? as u32,
|
number: row.get::<_, i64>(2)? as u32,
|
||||||
|
absolute: row.get(7)?,
|
||||||
|
entry_season: row.get(8)?,
|
||||||
|
entry_number: row.get(9)?,
|
||||||
name: row.get(3)?,
|
name: row.get(3)?,
|
||||||
overview: row.get(4)?,
|
overview: row.get(4)?,
|
||||||
still_url: row.get(5)?,
|
still_url: row.get(5)?,
|
||||||
@@ -855,13 +991,13 @@ impl Catalog {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which titles already have an episode list stored, and how fresh.
|
/// When each matched title was last enriched, for the provider it is
|
||||||
///
|
/// matched against. A title missing here has never been, or its match has
|
||||||
/// Enough to decide whether to ask again without reading every row: the
|
/// changed since.
|
||||||
/// answer is only ever compared against a TTL.
|
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
|
||||||
pub fn episode_metadata_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
|
|
||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, provider, MIN(fetched_at) FROM episode_metadata GROUP BY title_key",
|
"SELECT title_key, provider, enriched_at FROM title_metadata
|
||||||
|
WHERE enriched_at IS NOT NULL",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], |row| {
|
let rows = statement.query_map([], |row| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -873,15 +1009,80 @@ impl Catalog {
|
|||||||
|
|
||||||
let mut ages = HashMap::new();
|
let mut ages = HashMap::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (title_key, provider, fetched_at) = row?;
|
let (title_key, provider, enriched_at) = row?;
|
||||||
if let Some(provider) = ProviderId::parse(&provider) {
|
if let Some(provider) = ProviderId::parse(&provider) {
|
||||||
ages.insert(title_key, (provider, fetched_at));
|
ages.insert(title_key, (provider, enriched_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ages)
|
Ok(ages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace every episode row of one title with `episodes`, inside `tx`.
|
||||||
|
///
|
||||||
|
/// A replace rather than an upsert per row: a provider that has *dropped* an
|
||||||
|
/// episode — a special that was recounted, a season renumbered — should leave
|
||||||
|
/// nothing behind, and an episode list is small enough that the difference is a
|
||||||
|
/// few hundred microseconds.
|
||||||
|
fn replace_episodes(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
title_key: &str,
|
||||||
|
provider: ProviderId,
|
||||||
|
fetched_at: i64,
|
||||||
|
episodes: &[EpisodeMetadata],
|
||||||
|
) -> Result<()> {
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM episode_metadata WHERE title_key = ?1",
|
||||||
|
params![title_key],
|
||||||
|
)?;
|
||||||
|
let mut insert = tx.prepare(
|
||||||
|
"INSERT OR REPLACE INTO episode_metadata (
|
||||||
|
title_key, season, number, provider, fetched_at,
|
||||||
|
name, overview, still_url, air_date,
|
||||||
|
absolute, entry_season, entry_number
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
|
)?;
|
||||||
|
for episode in episodes {
|
||||||
|
insert.execute(params![
|
||||||
|
title_key,
|
||||||
|
episode.season.map_or(ABSOLUTE_SEASON, i64::from),
|
||||||
|
episode.number,
|
||||||
|
provider.as_str(),
|
||||||
|
fetched_at,
|
||||||
|
episode.name.as_deref(),
|
||||||
|
episode.overview.as_deref(),
|
||||||
|
episode.still_url.as_deref(),
|
||||||
|
episode.air_date.as_deref(),
|
||||||
|
episode.absolute,
|
||||||
|
episode.entry_season,
|
||||||
|
episode.entry_number,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `details` and `chain` columns, read back.
|
||||||
|
///
|
||||||
|
/// JSON that will not parse is treated as never fetched rather than failing
|
||||||
|
/// the read: the next match run fetches it again, where an error here would
|
||||||
|
/// take the whole grid down over one row.
|
||||||
|
fn details_column(details: Option<String>, chain: Option<String>) -> Option<TitleDetails> {
|
||||||
|
let mut details: TitleDetails = serde_json::from_str(&details?).ok()?;
|
||||||
|
details.chain = chain
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
Some(details)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
/// `None` for a row whose provider column this build does not recognise.
|
/// `None` for a row whose provider column this build does not recognise.
|
||||||
fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataRecord>> {
|
fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataRecord>> {
|
||||||
let title_key: String = row.get(0)?;
|
let title_key: String = row.get(0)?;
|
||||||
@@ -914,6 +1115,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
|
|||||||
.collect(),
|
.collect(),
|
||||||
episodes: row.get(14)?,
|
episodes: row.get(14)?,
|
||||||
url: row.get(15)?,
|
url: row.get(15)?,
|
||||||
|
details: details_column(row.get(17)?, row.get(18)?),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1198,6 +1400,7 @@ mod tests {
|
|||||||
genres: vec!["Action".into(), "Drama".into()],
|
genres: vec!["Action".into(), "Drama".into()],
|
||||||
episodes: Some(25),
|
episodes: Some(25),
|
||||||
url: Some("https://anilist.co/anime/16498".into()),
|
url: Some("https://anilist.co/anime/16498".into()),
|
||||||
|
details: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,6 +1484,9 @@ mod tests {
|
|||||||
&[EpisodeMetadata {
|
&[EpisodeMetadata {
|
||||||
season: None,
|
season: None,
|
||||||
number: 1,
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
name: Some("To You, in 2000 Years".into()),
|
name: Some("To You, in 2000 Years".into()),
|
||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
@@ -1370,6 +1576,9 @@ mod tests {
|
|||||||
EpisodeMetadata {
|
EpisodeMetadata {
|
||||||
season: None,
|
season: None,
|
||||||
number: 57,
|
number: 57,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
name: Some("The Immortal Legion".into()),
|
name: Some("The Immortal Legion".into()),
|
||||||
overview: Some("Ed and Al…".into()),
|
overview: Some("Ed and Al…".into()),
|
||||||
still_url: None,
|
still_url: None,
|
||||||
@@ -1378,6 +1587,9 @@ mod tests {
|
|||||||
EpisodeMetadata {
|
EpisodeMetadata {
|
||||||
season: Some(1),
|
season: Some(1),
|
||||||
number: 1,
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
name: Some("Fullmetal Alchemist".into()),
|
name: Some("Fullmetal Alchemist".into()),
|
||||||
overview: None,
|
overview: None,
|
||||||
still_url: Some("still.jpg".into()),
|
still_url: Some("still.jpg".into()),
|
||||||
@@ -1407,9 +1619,347 @@ mod tests {
|
|||||||
.expect("store again");
|
.expect("store again");
|
||||||
let stored = catalog.all_episode_metadata().expect("read");
|
let stored = catalog.all_episode_metadata().expect("read");
|
||||||
assert_eq!(stored["fma"].len(), 1);
|
assert_eq!(stored["fma"].len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
let ages = catalog.episode_metadata_ages().expect("ages");
|
/// The three numberings a release might use have to survive a restart, or
|
||||||
assert_eq!(ages["fma"], (ProviderId::AniList, 200));
|
/// an absolutely-numbered season folder stops finding its names.
|
||||||
|
#[test]
|
||||||
|
fn every_numbering_of_an_episode_round_trips() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let episode = EpisodeMetadata {
|
||||||
|
season: Some(2),
|
||||||
|
number: 11,
|
||||||
|
absolute: Some(39),
|
||||||
|
entry_season: Some(3),
|
||||||
|
entry_number: Some(1),
|
||||||
|
name: Some("Lightning's End".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_episode_metadata(
|
||||||
|
"mushishi",
|
||||||
|
ProviderId::AniList,
|
||||||
|
1,
|
||||||
|
std::slice::from_ref(&episode),
|
||||||
|
)
|
||||||
|
.expect("store");
|
||||||
|
let stored = catalog.all_episode_metadata().expect("read");
|
||||||
|
assert_eq!(stored["mushishi"].get(Some(2), 11), Some(&episode));
|
||||||
|
assert_eq!(stored["mushishi"].get(Some(3), 1), Some(&episode));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **What left Jujutsu Kaisen 0 with the TV series' episode names.** The
|
||||||
|
/// title was re-pinned to the film, the film had no episodes, and an empty
|
||||||
|
/// list was never written — so the series' list stayed.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_enrichment_clears_the_episodes_the_last_match_left() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let record = MetadataRecord {
|
||||||
|
title_key: "jujutsu kaisen 0 movie".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: 1,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&record).expect("store");
|
||||||
|
catalog
|
||||||
|
.set_episode_metadata(
|
||||||
|
&record.title_key,
|
||||||
|
ProviderId::AniList,
|
||||||
|
1,
|
||||||
|
&[EpisodeMetadata {
|
||||||
|
season: Some(1),
|
||||||
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
|
name: Some("Ryomen Sukuna".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("store episodes");
|
||||||
|
|
||||||
|
// Storing the same entry again keeps them; it is the same entry.
|
||||||
|
catalog.set_metadata(&record).expect("store again");
|
||||||
|
assert_eq!(catalog.all_episode_metadata().expect("read").len(), 1);
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.set_enrichment(
|
||||||
|
&record.title_key,
|
||||||
|
ProviderId::AniList,
|
||||||
|
5,
|
||||||
|
&Enrichment::default(),
|
||||||
|
)
|
||||||
|
.expect("enrich");
|
||||||
|
assert!(catalog.all_episode_metadata().expect("read").is_empty());
|
||||||
|
// Asked, and the answer was nothing: that is recorded, or a film is
|
||||||
|
// asked again on every run.
|
||||||
|
assert_eq!(
|
||||||
|
catalog
|
||||||
|
.enrichment_ages()
|
||||||
|
.expect("ages")
|
||||||
|
.get(&record.title_key),
|
||||||
|
Some(&(ProviderId::AniList, 5))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android pins a match without enriching it; until the next match run,
|
||||||
|
/// the title must show no episode names rather than the old entry's.
|
||||||
|
#[test]
|
||||||
|
fn pinning_a_different_entry_drops_the_old_entrys_episodes_at_once() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let series = MetadataRecord {
|
||||||
|
title_key: "jujutsu kaisen 0 movie".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: 1,
|
||||||
|
manual: false,
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&series).expect("store");
|
||||||
|
catalog
|
||||||
|
.set_episode_metadata(
|
||||||
|
&series.title_key,
|
||||||
|
ProviderId::AniList,
|
||||||
|
1,
|
||||||
|
&[EpisodeMetadata {
|
||||||
|
season: Some(1),
|
||||||
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
|
name: Some("Ryomen Sukuna".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("store episodes");
|
||||||
|
|
||||||
|
let film = MetadataRecord {
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
remote_id: "131573".into(),
|
||||||
|
..found()
|
||||||
|
}),
|
||||||
|
manual: true,
|
||||||
|
..series
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&film).expect("pin");
|
||||||
|
assert!(catalog.all_episode_metadata().expect("read").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrichment adds a backdrop when it found one and otherwise leaves the
|
||||||
|
/// match's own alone; a new match makes the title un-enriched again.
|
||||||
|
#[test]
|
||||||
|
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let record = MetadataRecord {
|
||||||
|
title_key: "attack on titan".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
backdrop_url: Some("old.jpg".into()),
|
||||||
|
..found()
|
||||||
|
}),
|
||||||
|
fetched_at: 1,
|
||||||
|
manual: false,
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&record).expect("store");
|
||||||
|
let backdrop = |catalog: &Catalog| {
|
||||||
|
catalog
|
||||||
|
.metadata("attack on titan")
|
||||||
|
.expect("read")
|
||||||
|
.and_then(|record| record.metadata)
|
||||||
|
.and_then(|found| found.backdrop_url)
|
||||||
|
};
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.set_enrichment(
|
||||||
|
"attack on titan",
|
||||||
|
ProviderId::AniList,
|
||||||
|
2,
|
||||||
|
&Enrichment::default(),
|
||||||
|
)
|
||||||
|
.expect("enrich");
|
||||||
|
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg"));
|
||||||
|
|
||||||
|
let enrichment = Enrichment {
|
||||||
|
backdrop_url: Some("fanart.jpg".into()),
|
||||||
|
..Enrichment::default()
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
|
||||||
|
.expect("enrich again");
|
||||||
|
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
|
||||||
|
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
remote_id: "99147".into(),
|
||||||
|
..found()
|
||||||
|
}),
|
||||||
|
..record
|
||||||
|
})
|
||||||
|
.expect("match something else");
|
||||||
|
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Details round-trip, and refreshing a match's details — the same entry
|
||||||
|
/// stored again — keeps what enrichment found for it.
|
||||||
|
#[test]
|
||||||
|
fn refreshing_the_same_match_keeps_its_enrichment_and_a_new_one_does_not() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
let record = MetadataRecord {
|
||||||
|
title_key: "mushishi".into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(found()),
|
||||||
|
fetched_at: 1,
|
||||||
|
manual: true,
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&record).expect("store");
|
||||||
|
let enrichment = Enrichment {
|
||||||
|
episodes: vec![EpisodeMetadata {
|
||||||
|
season: Some(1),
|
||||||
|
number: 1,
|
||||||
|
absolute: Some(1),
|
||||||
|
entry_season: Some(1),
|
||||||
|
entry_number: Some(1),
|
||||||
|
name: Some("The Green Seat".into()),
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
}],
|
||||||
|
backdrop_url: Some("fanart.jpg".into()),
|
||||||
|
chain: vec!["457".into(), "20595".into()],
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_enrichment("mushishi", ProviderId::AniList, 2, &enrichment)
|
||||||
|
.expect("enrich");
|
||||||
|
|
||||||
|
let refreshed = MetadataRecord {
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
details: Some(TitleDetails {
|
||||||
|
studios: vec!["Artland".into()],
|
||||||
|
..TitleDetails::default()
|
||||||
|
}),
|
||||||
|
..found()
|
||||||
|
}),
|
||||||
|
fetched_at: 3,
|
||||||
|
..record.clone()
|
||||||
|
};
|
||||||
|
catalog.set_metadata(&refreshed).expect("refresh");
|
||||||
|
let read = catalog.metadata("mushishi").expect("read").expect("a row");
|
||||||
|
let data = read.metadata.expect("matched");
|
||||||
|
assert_eq!(data.backdrop_url.as_deref(), Some("fanart.jpg"));
|
||||||
|
let details = data.details.expect("details");
|
||||||
|
assert_eq!(details.studios, vec!["Artland"]);
|
||||||
|
assert_eq!(details.chain, vec!["457", "20595"]);
|
||||||
|
assert!(
|
||||||
|
catalog
|
||||||
|
.enrichment_ages()
|
||||||
|
.expect("ages")
|
||||||
|
.contains_key("mushishi")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
catalog.all_episode_metadata().expect("read")["mushishi"].len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different entry is a different answer: nothing carries over.
|
||||||
|
catalog
|
||||||
|
.set_metadata(&MetadataRecord {
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
remote_id: "20526".into(),
|
||||||
|
..found()
|
||||||
|
}),
|
||||||
|
..record
|
||||||
|
})
|
||||||
|
.expect("repin");
|
||||||
|
let read = catalog.metadata("mushishi").expect("read").expect("a row");
|
||||||
|
assert_eq!(read.metadata.and_then(|data| data.backdrop_url), None);
|
||||||
|
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file keeps the moment it first appeared across recrawls, and one
|
||||||
|
/// that was there before this was recorded reads as 0.
|
||||||
|
#[test]
|
||||||
|
fn a_file_remembers_when_it_first_appeared() {
|
||||||
|
let mut catalog = Catalog::in_memory().expect("open");
|
||||||
|
catalog
|
||||||
|
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||||
|
.expect("crawl");
|
||||||
|
let first = catalog.first_seen().expect("read")[&("s".to_string(), "a".to_string())];
|
||||||
|
assert!(first > 0);
|
||||||
|
catalog
|
||||||
|
.conn
|
||||||
|
.execute("UPDATE node_first_seen SET seen_at = 5", [])
|
||||||
|
.expect("age it");
|
||||||
|
catalog
|
||||||
|
.replace_share(
|
||||||
|
"s",
|
||||||
|
&[
|
||||||
|
file("s", "a", "Show S01E01.mkv"),
|
||||||
|
file("s", "b", "Show S01E02.mkv"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("recrawl");
|
||||||
|
let seen = catalog.first_seen().expect("read");
|
||||||
|
assert_eq!(seen[&("s".to_string(), "a".to_string())], 5);
|
||||||
|
assert!(seen[&("s".to_string(), "b".to_string())] > 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema v9 repairs what AniList's first episode source wrote: its
|
||||||
|
/// episode rows and its banners go, and the matches — hand-picked ones
|
||||||
|
/// included — stay.
|
||||||
|
#[test]
|
||||||
|
fn upgrading_drops_anilists_episode_rows_and_banners_but_keeps_its_matches() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open");
|
||||||
|
for migration in [
|
||||||
|
MIGRATION_V1,
|
||||||
|
MIGRATION_V2,
|
||||||
|
MIGRATION_V3,
|
||||||
|
MIGRATION_V4,
|
||||||
|
MIGRATION_V5,
|
||||||
|
MIGRATION_V6,
|
||||||
|
MIGRATION_V7,
|
||||||
|
MIGRATION_V8,
|
||||||
|
] {
|
||||||
|
conn.execute_batch(migration).expect("migrate");
|
||||||
|
}
|
||||||
|
conn.pragma_update(None, "user_version", 8)
|
||||||
|
.expect("set version");
|
||||||
|
conn.execute_batch(
|
||||||
|
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
|
||||||
|
name, kind, backdrop_url, manual)
|
||||||
|
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
|
||||||
|
'banner.jpg', 1),
|
||||||
|
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
|
||||||
|
'backdrop.jpg', 0);
|
||||||
|
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
|
||||||
|
VALUES ('aot', 2, 1, 'anilist', 1, 'Season one''s first, wrongly'),
|
||||||
|
('got', 1, 1, 'tmdb', 1, 'Winter Is Coming');",
|
||||||
|
)
|
||||||
|
.expect("seed");
|
||||||
|
|
||||||
|
let catalog = Catalog::from_connection(conn).expect("upgrade");
|
||||||
|
let records = catalog.all_metadata().expect("read");
|
||||||
|
let aot = records["aot"].metadata.as_ref().expect("still matched");
|
||||||
|
assert!(records["aot"].manual);
|
||||||
|
assert_eq!(aot.backdrop_url, None);
|
||||||
|
assert_eq!(
|
||||||
|
records["got"]
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|got| got.backdrop_url.as_deref()),
|
||||||
|
Some("backdrop.jpg")
|
||||||
|
);
|
||||||
|
|
||||||
|
let episodes = catalog.all_episode_metadata().expect("read");
|
||||||
|
assert!(!episodes.contains_key("aot"));
|
||||||
|
assert_eq!(episodes["got"].len(), 1);
|
||||||
|
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1423,6 +1973,9 @@ mod tests {
|
|||||||
&[EpisodeMetadata {
|
&[EpisodeMetadata {
|
||||||
season: None,
|
season: None,
|
||||||
number: 1,
|
number: 1,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
name: Some("Fullmetal Alchemist".into()),
|
name: Some("Fullmetal Alchemist".into()),
|
||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,18 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod appearance;
|
pub mod appearance;
|
||||||
|
pub mod browse;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod chapters;
|
pub mod chapters;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod franchise;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod naming;
|
pub mod naming;
|
||||||
pub mod prefs;
|
pub mod prefs;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
|
pub mod shelves;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|||||||
@@ -389,6 +389,27 @@ impl Library {
|
|||||||
played
|
played
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When each title last gained a file, Unix seconds — the newest
|
||||||
|
/// [`crate::catalog::Catalog::first_seen`] among its episodes. `0` for a
|
||||||
|
/// title whose files were all there before that was recorded.
|
||||||
|
pub fn added_at(&self, first_seen: &HashMap<(String, String), i64>) -> HashMap<String, i64> {
|
||||||
|
self.titles
|
||||||
|
.iter()
|
||||||
|
.map(|title| {
|
||||||
|
let newest = title
|
||||||
|
.episodes()
|
||||||
|
.filter_map(|episode| {
|
||||||
|
first_seen
|
||||||
|
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
(title.key.clone(), newest)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Substring match over the display name and every filename under it, so
|
/// Substring match over the display name and every filename under it, so
|
||||||
/// searching for a release group or an episode name finds its title.
|
/// searching for a release group or an episode name finds its title.
|
||||||
pub fn search(&self, query: &str) -> Vec<&Title> {
|
pub fn search(&self, query: &str) -> Vec<&Title> {
|
||||||
@@ -438,7 +459,7 @@ pub fn title_key(title: &str) -> String {
|
|||||||
|
|
||||||
/// Sort key for the shelf: case-insensitive, and a leading article ignored, so
|
/// Sort key for the shelf: case-insensitive, and a leading article ignored, so
|
||||||
/// `The Expanse` files under E where a viewer looks for it.
|
/// `The Expanse` files under E where a viewer looks for it.
|
||||||
fn sort_name(name: &str) -> String {
|
pub fn sort_name(name: &str) -> String {
|
||||||
let lower = name.to_lowercase();
|
let lower = name.to_lowercase();
|
||||||
for article in ["the ", "a ", "an "] {
|
for article in ["the ", "a ", "an "] {
|
||||||
if let Some(rest) = lower.strip_prefix(article) {
|
if let Some(rest) = lower.strip_prefix(article) {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! thumbnails at upload time and that client attaches none. See
|
//! thumbnails at upload time and that client attaches none. See
|
||||||
//! `docs/DEVELOPMENT.md`.
|
//! `docs/DEVELOPMENT.md`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ use crate::library::TitleKind;
|
|||||||
/// Stored as a string in the catalog rather than an integer: a row whose
|
/// Stored as a string in the catalog rather than an integer: a row whose
|
||||||
/// provider this build does not recognise should read as "some other provider",
|
/// provider this build does not recognise should read as "some other provider",
|
||||||
/// which is a re-match, not a parse failure.
|
/// which is a re-match, not a parse failure.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ProviderId {
|
pub enum ProviderId {
|
||||||
/// AniList. Anime only, no API key, no account.
|
/// AniList. Anime only, no API key, no account.
|
||||||
@@ -62,7 +62,10 @@ impl ProviderId {
|
|||||||
/// One line on what it covers and what it costs to use.
|
/// One line on what it covers and what it costs to use.
|
||||||
pub fn description(self) -> &'static str {
|
pub fn description(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::AniList => "Anime, with no API key and no account.",
|
Self::AniList => {
|
||||||
|
"Anime, with no API key and no account. Episode names and wide art come from \
|
||||||
|
ani.zip, which is sent the matched ids."
|
||||||
|
}
|
||||||
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
|
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +112,156 @@ pub struct TitleMetadata {
|
|||||||
pub episodes: Option<u32>,
|
pub episodes: Option<u32>,
|
||||||
/// The title's page, for a viewer who wants the rest of it.
|
/// The title's page, for a viewer who wants the rest of it.
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
|
/// Everything else the provider said: what the library is sorted, grouped
|
||||||
|
/// and shelved by. `None` on a record stored before there was any of it,
|
||||||
|
/// which is what tells a match run to fetch it — see
|
||||||
|
/// `pstr_meta::service::plan`.
|
||||||
|
pub details: Option<TitleDetails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a provider says about a title beyond what a tile draws.
|
||||||
|
///
|
||||||
|
/// Stored whole as one JSON column rather than a column each: nothing here is
|
||||||
|
/// ever queried by SQLite, every field is optional at every provider, and a
|
||||||
|
/// field added later has to read as absent from a row written before it, not
|
||||||
|
/// fail the row. TMDB fills none of it yet.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct TitleDetails {
|
||||||
|
/// The title in romaji — `Sousou no Frieren` — for a viewer who would
|
||||||
|
/// rather see that than the English one.
|
||||||
|
pub romaji: Option<String>,
|
||||||
|
/// AniList's ultra-wide banner, around 4.75:1. Never drawn into a 16:9
|
||||||
|
/// tile — it is what a strip that shape falls back to when there is no
|
||||||
|
/// fanart.
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
/// As the provider writes it: `TV`, `TV_SHORT`, `MOVIE`, `SPECIAL`, `OVA`,
|
||||||
|
/// `ONA`, `MUSIC`. [`TitleDetails::format_label`] is what is shown.
|
||||||
|
pub format: Option<String>,
|
||||||
|
/// The broadcast season: `WINTER`, `SPRING`, `SUMMER` or `FALL`.
|
||||||
|
pub season: Option<String>,
|
||||||
|
/// When it first aired, `YYYY-MM-DD` with whatever parts are known —
|
||||||
|
/// `2013-04`, `2013`. Sorts as text, which is why it is kept that way.
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
/// The main animation studios.
|
||||||
|
pub studios: Vec<String>,
|
||||||
|
pub directors: Vec<String>,
|
||||||
|
/// The provider's descriptive tags, most relevant first, spoilers left out.
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// How many people follow it at the provider. Only ever compared with
|
||||||
|
/// other titles from the same provider.
|
||||||
|
pub popularity: Option<u32>,
|
||||||
|
/// `FINISHED`, `RELEASING`, `NOT_YET_RELEASED`, `CANCELLED` or `HIATUS`.
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// The next episode to air, and when — Unix seconds. As of when the
|
||||||
|
/// answer was fetched, so a time in the past means nothing.
|
||||||
|
pub next_episode: Option<u32>,
|
||||||
|
pub next_airing_at: Option<i64>,
|
||||||
|
/// The provider ids of entries in the same story — prequels, sequels,
|
||||||
|
/// side stories, recaps, retellings. What the library groups a franchise
|
||||||
|
/// by; see `crate::franchise`.
|
||||||
|
pub related: Vec<String>,
|
||||||
|
/// The entries enrichment walked through to find the title's episodes —
|
||||||
|
/// its own sequels, by provider id. Stored apart from the rest, because
|
||||||
|
/// it is enrichment's answer rather than the match's.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub chain: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TitleDetails {
|
||||||
|
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
|
||||||
|
pub fn format_label(&self) -> Option<&'static str> {
|
||||||
|
Some(match self.format.as_deref()? {
|
||||||
|
"TV" => "TV",
|
||||||
|
"TV_SHORT" => "TV short",
|
||||||
|
"MOVIE" => "Film",
|
||||||
|
"SPECIAL" => "Special",
|
||||||
|
"OVA" => "OVA",
|
||||||
|
"ONA" => "ONA",
|
||||||
|
"MUSIC" => "Music video",
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Spring 2013`, or just the year.
|
||||||
|
pub fn season_label(&self) -> Option<String> {
|
||||||
|
let year = self.start_date.as_deref()?.get(..4)?;
|
||||||
|
let season = match self.season.as_deref() {
|
||||||
|
Some("WINTER") => "Winter",
|
||||||
|
Some("SPRING") => "Spring",
|
||||||
|
Some("SUMMER") => "Summer",
|
||||||
|
Some("FALL") => "Fall",
|
||||||
|
_ => return Some(year.to_string()),
|
||||||
|
};
|
||||||
|
Some(format!("{season} {year}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether it is still airing.
|
||||||
|
pub fn is_airing(&self) -> bool {
|
||||||
|
self.status.as_deref() == Some("RELEASING")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next episode and when it airs, when that is still after `now`.
|
||||||
|
pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> {
|
||||||
|
let at = self.next_airing_at.filter(|at| *at > now)?;
|
||||||
|
Some((self.next_episode?, at))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TitleMetadata {
|
||||||
|
/// Ultra-wide art: the backdrop where there is one, else the banner.
|
||||||
|
///
|
||||||
|
/// For the library's hero and the title page's band, which are much wider
|
||||||
|
/// than 16:9 — a backdrop cropped to them loses some sky, a banner fits
|
||||||
|
/// them as drawn.
|
||||||
|
pub fn wide_art(&self) -> Option<&str> {
|
||||||
|
self.backdrop_url.as_deref().or_else(|| {
|
||||||
|
self.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|details| details.banner_url.as_deref())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name to show, as the viewer asked for it.
|
||||||
|
pub fn display_name(&self, names: TitleNames) -> Option<&str> {
|
||||||
|
match names {
|
||||||
|
TitleNames::Library => None,
|
||||||
|
TitleNames::English => Some(&self.name),
|
||||||
|
TitleNames::Romaji => self
|
||||||
|
.details
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|details| details.romaji.as_deref())
|
||||||
|
.or(Some(&self.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which name a title goes by in the library.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum TitleNames {
|
||||||
|
/// The folder's — what the share calls it.
|
||||||
|
Library,
|
||||||
|
/// The provider's English title, where it has one. The default, because
|
||||||
|
/// it is what Android always showed, and a folder name is whatever an
|
||||||
|
/// uploader typed — `Kaijuu 8-gou`, `Fate Stay Night - Heaven s Feel -
|
||||||
|
/// THE MOVIE I presage flower`.
|
||||||
|
#[default]
|
||||||
|
English,
|
||||||
|
/// The provider's romaji title.
|
||||||
|
Romaji,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TitleNames {
|
||||||
|
pub const ALL: [Self; 3] = [Self::Library, Self::English, Self::Romaji];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Library => "As in the share",
|
||||||
|
Self::English => "English",
|
||||||
|
Self::Romaji => "Romaji",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TitleMetadata {
|
impl TitleMetadata {
|
||||||
@@ -139,6 +292,23 @@ pub struct EpisodeMetadata {
|
|||||||
/// anime releases name their files.
|
/// anime releases name their files.
|
||||||
pub season: Option<u32>,
|
pub season: Option<u32>,
|
||||||
pub number: u32,
|
pub number: u32,
|
||||||
|
/// Its place counting straight through every season, when the provider
|
||||||
|
/// knows it. What an absolutely-numbered file inside a season folder —
|
||||||
|
/// `Attack on Titan/Season 2/… - 26.mkv` — is matched on.
|
||||||
|
#[serde(default)]
|
||||||
|
pub absolute: Option<u32>,
|
||||||
|
/// Its season and number counting each provider *entry* as a season: the
|
||||||
|
/// position of the entry it belongs to among the franchise's series
|
||||||
|
/// entries, and its number within that entry.
|
||||||
|
///
|
||||||
|
/// Not TVDB's season. A release that follows AniList rather than TVDB
|
||||||
|
/// names Mushishi's *Zoku Shou 2* `S03`, where TVDB files it as the second
|
||||||
|
/// half of season two; this is what that file is answered from. See
|
||||||
|
/// [`EpisodeGuide::get`] for when it is allowed to be.
|
||||||
|
#[serde(default)]
|
||||||
|
pub entry_season: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub entry_number: Option<u32>,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub overview: Option<String>,
|
pub overview: Option<String>,
|
||||||
/// A frame from the episode, around 16:9.
|
/// A frame from the episode, around 16:9.
|
||||||
@@ -149,6 +319,23 @@ pub struct EpisodeMetadata {
|
|||||||
pub air_date: Option<String>,
|
pub air_date: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What enriching a matched title found: its episodes, and art the search
|
||||||
|
/// answer did not carry.
|
||||||
|
///
|
||||||
|
/// Stored as one unit by `Catalog::set_enrichment`, and recorded as asked even
|
||||||
|
/// when both are empty — a film has no episode list, and without the record it
|
||||||
|
/// would be asked again on every match run.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct Enrichment {
|
||||||
|
pub episodes: Vec<EpisodeMetadata>,
|
||||||
|
/// A 16:9 picture of the whole title, when one was found. `None` leaves
|
||||||
|
/// whatever backdrop the match already had.
|
||||||
|
pub backdrop_url: Option<String>,
|
||||||
|
/// Every provider entry the episodes were taken from, the match's own
|
||||||
|
/// included. See [`TitleDetails::chain`].
|
||||||
|
pub chain: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Every episode a provider listed for one title, ready to look up.
|
/// Every episode a provider listed for one title, ready to look up.
|
||||||
///
|
///
|
||||||
/// The lookup is the whole reason this is a type rather than a `Vec`: a file
|
/// The lookup is the whole reason this is a type rather than a `Vec`: a file
|
||||||
@@ -160,16 +347,33 @@ pub struct EpisodeGuide {
|
|||||||
/// Keyed `(season, number)`, where `None` is the provider's own absolute
|
/// Keyed `(season, number)`, where `None` is the provider's own absolute
|
||||||
/// numbering.
|
/// numbering.
|
||||||
entries: HashMap<(Option<u32>, u32), EpisodeMetadata>,
|
entries: HashMap<(Option<u32>, u32), EpisodeMetadata>,
|
||||||
|
/// [`EpisodeMetadata::absolute`] to the key of the entry holding it.
|
||||||
|
absolute: HashMap<u32, (Option<u32>, u32)>,
|
||||||
|
/// [`EpisodeMetadata::entry_season`] and `entry_number` to the key of the
|
||||||
|
/// entry holding them.
|
||||||
|
by_entry: HashMap<(u32, u32), (Option<u32>, u32)>,
|
||||||
|
/// Every season some entry is filed under, for deciding when the entry
|
||||||
|
/// numbering may answer.
|
||||||
|
stated: HashSet<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EpisodeGuide {
|
impl EpisodeGuide {
|
||||||
pub fn new(episodes: Vec<EpisodeMetadata>) -> Self {
|
pub fn new(episodes: Vec<EpisodeMetadata>) -> Self {
|
||||||
Self {
|
let mut guide = Self::default();
|
||||||
entries: episodes
|
for episode in episodes {
|
||||||
.into_iter()
|
let key = (episode.season, episode.number);
|
||||||
.map(|episode| ((episode.season, episode.number), episode))
|
if let Some(absolute) = episode.absolute {
|
||||||
.collect(),
|
guide.absolute.insert(absolute, key);
|
||||||
|
}
|
||||||
|
if let (Some(season), Some(number)) = (episode.entry_season, episode.entry_number) {
|
||||||
|
guide.by_entry.insert((season, number), key);
|
||||||
|
}
|
||||||
|
if let Some(season) = episode.season {
|
||||||
|
guide.stated.insert(season);
|
||||||
|
}
|
||||||
|
guide.entries.insert(key, episode);
|
||||||
}
|
}
|
||||||
|
guide
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -182,20 +386,25 @@ impl EpisodeGuide {
|
|||||||
|
|
||||||
/// What the provider says about the file numbered like this.
|
/// What the provider says about the file numbered like this.
|
||||||
///
|
///
|
||||||
/// Tried in order: the season and number the filename states; the
|
/// Tried in order:
|
||||||
/// provider's absolute numbering; and, for a file with no season at all,
|
|
||||||
/// season one. That second fallback is what makes an absolutely-numbered
|
|
||||||
/// release line up with a provider that files everything under a single
|
|
||||||
/// season — the common case for anime on TMDB.
|
|
||||||
///
|
///
|
||||||
/// **The absolute fallback stops at season one.** A provider that numbers
|
/// 1. the season and number the filename states;
|
||||||
/// straight through is one that split nothing, so its episode seven is the
|
/// 2. for a file in no season or in season one, the provider's own absolute
|
||||||
/// seventh of the show — which is season one's, not season two's. Answering
|
/// list, and for a file in no season, season one — what makes `#057`
|
||||||
/// `S02E07` with it captions every episode of every later season with the
|
/// line up with TMDB filing a 64-episode anime under one season;
|
||||||
/// wrong name, and a wrong caption does not look like a bug: it looks like
|
/// 3. the absolute number, into the season the file states — what makes
|
||||||
/// the library is wrong. Season two upwards is answered from an entry that
|
/// `Season 2/… - 26.mkv` the second season's first episode;
|
||||||
/// states that season or not at all — see
|
/// 4. the entry numbering, for a season the provider files nothing under.
|
||||||
/// `Provider::seasons_are_separate_entries`.
|
///
|
||||||
|
/// **No fallback crosses into another season.** Step 2 stops at season one
|
||||||
|
/// and step 3 only answers inside the season the file named, because
|
||||||
|
/// answering `S02E07` with season one's seventh episode captions every
|
||||||
|
/// episode of every later season with the wrong name — and a wrong caption
|
||||||
|
/// does not look like a bug, it looks like the library is wrong. Step 4
|
||||||
|
/// only answers a season the provider has nothing filed under, so a release
|
||||||
|
/// numbered the provider's way is never re-read the other way: Mushoku
|
||||||
|
/// Tensei's second *entry* is the second half of season one, and its
|
||||||
|
/// `S02E01` is season two's.
|
||||||
pub fn get(&self, season: Option<u32>, number: u32) -> Option<&EpisodeMetadata> {
|
pub fn get(&self, season: Option<u32>, number: u32) -> Option<&EpisodeMetadata> {
|
||||||
if let Some(found) = self.entries.get(&(season, number)) {
|
if let Some(found) = self.entries.get(&(season, number)) {
|
||||||
return Some(found);
|
return Some(found);
|
||||||
@@ -205,14 +414,31 @@ impl EpisodeGuide {
|
|||||||
{
|
{
|
||||||
return Some(found);
|
return Some(found);
|
||||||
}
|
}
|
||||||
season
|
if season.is_none()
|
||||||
.is_none()
|
&& let Some(found) = self.entries.get(&(Some(1), number))
|
||||||
.then(|| self.entries.get(&(Some(1), number)))?
|
{
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
if let Some(found) = self
|
||||||
|
.absolute
|
||||||
|
.get(&number)
|
||||||
|
.and_then(|key| self.entries.get(key))
|
||||||
|
&& (season.is_none() || found.season == season)
|
||||||
|
{
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
let season = season?;
|
||||||
|
if self.stated.contains(&season) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.by_entry
|
||||||
|
.get(&(season, number))
|
||||||
|
.and_then(|key| self.entries.get(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which seasons this guide actually has answers for. `None` is the
|
/// Which seasons this guide actually has answers for. `None` is the
|
||||||
/// provider's own absolute numbering.
|
/// provider's own absolute numbering.
|
||||||
pub fn seasons(&self) -> std::collections::HashSet<Option<u32>> {
|
pub fn seasons(&self) -> HashSet<Option<u32>> {
|
||||||
self.entries
|
self.entries
|
||||||
.values()
|
.values()
|
||||||
.map(|episode| episode.season)
|
.map(|episode| episode.season)
|
||||||
@@ -328,6 +554,7 @@ mod tests {
|
|||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: Some(26),
|
episodes: Some(26),
|
||||||
url: None,
|
url: None,
|
||||||
|
details: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +562,9 @@ mod tests {
|
|||||||
EpisodeMetadata {
|
EpisodeMetadata {
|
||||||
season,
|
season,
|
||||||
number,
|
number,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
overview: None,
|
overview: None,
|
||||||
still_url: None,
|
still_url: None,
|
||||||
@@ -342,6 +572,89 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An episode as ani.zip describes one: TVDB's season and number, TVDB's
|
||||||
|
/// absolute number, and its place in the AniList entry it came from.
|
||||||
|
fn numbered(
|
||||||
|
(season, number): (u32, u32),
|
||||||
|
absolute: u32,
|
||||||
|
(entry_season, entry_number): (u32, u32),
|
||||||
|
name: &str,
|
||||||
|
) -> EpisodeMetadata {
|
||||||
|
EpisodeMetadata {
|
||||||
|
absolute: Some(absolute),
|
||||||
|
entry_season: Some(entry_season),
|
||||||
|
entry_number: Some(entry_number),
|
||||||
|
..episode(Some(season), number, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_of(guide: &EpisodeGuide, season: Option<u32>, number: u32) -> Option<String> {
|
||||||
|
guide.get(season, number).and_then(|e| e.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Attack on Titan/Season 2/[Anime Time] Attack On Titan - 26.mkv`: a
|
||||||
|
/// season folder full of absolutely-numbered files.
|
||||||
|
#[test]
|
||||||
|
fn an_absolute_number_in_a_season_folder_finds_that_seasons_episode() {
|
||||||
|
let guide = EpisodeGuide::new(vec![
|
||||||
|
numbered((1, 1), 1, (1, 1), "To You, in 2000 Years"),
|
||||||
|
numbered((2, 1), 26, (2, 1), "Beast Titan"),
|
||||||
|
]);
|
||||||
|
assert_eq!(name_of(&guide, Some(2), 26).as_deref(), Some("Beast Titan"));
|
||||||
|
// And with no season at all, the absolute number is the whole answer.
|
||||||
|
assert_eq!(name_of(&guide, None, 26).as_deref(), Some("Beast Titan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The absolute fallback must not carry a file into a season it did not
|
||||||
|
/// name: `S03E13` of a show whose 13th episode is in season one is not that
|
||||||
|
/// episode.
|
||||||
|
#[test]
|
||||||
|
fn an_absolute_number_never_answers_for_another_season() {
|
||||||
|
let guide = EpisodeGuide::new(vec![
|
||||||
|
numbered((1, 13), 13, (1, 13), "Season one's thirteenth"),
|
||||||
|
numbered((3, 1), 38, (3, 1), "Smoke Signal"),
|
||||||
|
]);
|
||||||
|
assert_eq!(guide.get(Some(3), 13), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mushishi: TVDB files both halves of *Zoku Shou* as season two, and the
|
||||||
|
/// release calls the second half `S03`. Season three exists nowhere at the
|
||||||
|
/// provider, so the entry numbering answers it.
|
||||||
|
#[test]
|
||||||
|
fn a_season_the_provider_does_not_have_is_answered_by_entry() {
|
||||||
|
let guide = EpisodeGuide::new(vec![
|
||||||
|
numbered((1, 1), 1, (1, 1), "The Green Seat"),
|
||||||
|
numbered((2, 1), 28, (2, 1), "The Hand That Caresses the Night"),
|
||||||
|
numbered((2, 11), 39, (3, 1), "Lightning's End"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
name_of(&guide, Some(3), 1).as_deref(),
|
||||||
|
Some("Lightning's End")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
name_of(&guide, Some(2), 1).as_deref(),
|
||||||
|
Some("The Hand That Caresses the Night")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mushoku Tensei: AniList's second entry is the second half of TVDB's
|
||||||
|
/// season one. A release numbered TVDB's way names season two `S02`, and
|
||||||
|
/// the entry numbering must not answer it with that second half.
|
||||||
|
#[test]
|
||||||
|
fn the_entry_numbering_never_overrides_a_season_the_provider_has() {
|
||||||
|
let guide = EpisodeGuide::new(vec![
|
||||||
|
numbered((1, 12), 12, (2, 1), "Cour two's first"),
|
||||||
|
numbered((2, 1), 26, (3, 1), "Season two's first"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
name_of(&guide, Some(2), 1).as_deref(),
|
||||||
|
Some("Season two's first")
|
||||||
|
);
|
||||||
|
// A second-season episode the provider does not list is unanswered,
|
||||||
|
// not answered from the entry numbering.
|
||||||
|
assert_eq!(guide.get(Some(2), 2), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_that_states_its_season_is_answered_from_that_season() {
|
fn a_file_that_states_its_season_is_answered_from_that_season() {
|
||||||
let guide = EpisodeGuide::new(vec![
|
let guide = EpisodeGuide::new(vec![
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! Rows of titles that share something, for the top of the library.
|
||||||
|
//!
|
||||||
|
//! A grid sorted one way answers one question. Shelves answer the ones a
|
||||||
|
//! viewer did not think to ask: what is still airing, what else that studio
|
||||||
|
//! made, the director whose four films happen to be in the share. They are
|
||||||
|
//! picked from what the provider said, and only where there is enough of
|
||||||
|
//! something to be worth a row — three titles from one studio is a shelf, one
|
||||||
|
//! is a fact on its page.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use crate::library::Title;
|
||||||
|
use crate::metadata::MetadataRecord;
|
||||||
|
|
||||||
|
/// How many franchises a person or studio needs before it earns a row.
|
||||||
|
///
|
||||||
|
/// Franchises, not titles: Made in Abyss and its three films are four titles
|
||||||
|
/// with one director, and a "Directed by" row holding nothing but them says
|
||||||
|
/// what the franchise's own tile already does.
|
||||||
|
const MIN_TITLES: usize = 3;
|
||||||
|
|
||||||
|
/// A genre on more than this share of the matched titles says nothing: an
|
||||||
|
/// "Action" row in an anime library is half the library again — which is what
|
||||||
|
/// it was, at 0.6, in the library this was tuned on.
|
||||||
|
const MAX_GENRE_SHARE: f32 = 0.4;
|
||||||
|
|
||||||
|
/// One row: a heading, and the keys of the titles under it, in library order.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Shelf {
|
||||||
|
pub label: String,
|
||||||
|
pub keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Up to `limit` shelves for `titles`, most specific first: what is airing,
|
||||||
|
/// then directors, studios and genres, each by how many titles it holds.
|
||||||
|
///
|
||||||
|
/// Two shelves holding exactly the same titles are one shelf — the first,
|
||||||
|
/// which is the more specific.
|
||||||
|
pub fn pick(
|
||||||
|
titles: &[Title],
|
||||||
|
metadata: &HashMap<String, MetadataRecord>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<Shelf> {
|
||||||
|
let mut airing = Vec::new();
|
||||||
|
let mut directors: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||||
|
let mut studios: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||||
|
let mut genres: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||||
|
let mut matched = 0usize;
|
||||||
|
let refs: Vec<&Title> = titles.iter().collect();
|
||||||
|
let franchise_of: HashMap<&str, usize> = crate::franchise::group(&refs, metadata)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(at, franchise)| {
|
||||||
|
franchise
|
||||||
|
.members
|
||||||
|
.into_iter()
|
||||||
|
.map(move |member| (titles[member].key.as_str(), at))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let franchises = |keys: &[String]| {
|
||||||
|
keys.iter()
|
||||||
|
.filter_map(|key| franchise_of.get(key.as_str()))
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.len()
|
||||||
|
};
|
||||||
|
|
||||||
|
for title in titles {
|
||||||
|
let Some(found) = metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
matched += 1;
|
||||||
|
for genre in &found.genres {
|
||||||
|
genres.entry(genre).or_default().push(title.key.clone());
|
||||||
|
}
|
||||||
|
let Some(details) = &found.details else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if details.is_airing() {
|
||||||
|
airing.push(title.key.clone());
|
||||||
|
}
|
||||||
|
for director in &details.directors {
|
||||||
|
directors
|
||||||
|
.entry(director)
|
||||||
|
.or_default()
|
||||||
|
.push(title.key.clone());
|
||||||
|
}
|
||||||
|
for studio in &details.studios {
|
||||||
|
studios.entry(studio).or_default().push(title.key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ranked = |groups: BTreeMap<&str, Vec<String>>, label: &dyn Fn(&str) -> String| {
|
||||||
|
let mut rows: Vec<Shelf> = groups
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, keys)| franchises(keys) >= MIN_TITLES)
|
||||||
|
.map(|(name, keys)| Shelf {
|
||||||
|
label: label(name),
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Stable, so equal counts keep their alphabetical order.
|
||||||
|
rows.sort_by_key(|shelf| std::cmp::Reverse(shelf.keys.len()));
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
|
||||||
|
let genre_ceiling = (matched as f32 * MAX_GENRE_SHARE).floor() as usize;
|
||||||
|
let genres: BTreeMap<&str, Vec<String>> = genres
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, keys)| keys.len() <= genre_ceiling)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut shelves = Vec::new();
|
||||||
|
if !airing.is_empty() {
|
||||||
|
shelves.push(Shelf {
|
||||||
|
label: "Airing now".into(),
|
||||||
|
keys: airing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
shelves.extend(ranked(directors, &|name| format!("Directed by {name}")));
|
||||||
|
shelves.extend(ranked(studios, &|name| format!("From {name}")));
|
||||||
|
shelves.extend(ranked(genres, &|name| name.to_string()));
|
||||||
|
|
||||||
|
let mut picked: Vec<Shelf> = Vec::new();
|
||||||
|
for shelf in shelves {
|
||||||
|
if picked.len() == limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !picked.iter().any(|other| other.keys == shelf.keys) {
|
||||||
|
picked.push(shelf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picked
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::TitleKind;
|
||||||
|
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
|
||||||
|
|
||||||
|
fn title(key: &str) -> Title {
|
||||||
|
Title {
|
||||||
|
key: key.into(),
|
||||||
|
name: key.into(),
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Film,
|
||||||
|
seasons: Vec::new(),
|
||||||
|
share_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(key: &str, genres: &[&str], details: TitleDetails) -> (String, MetadataRecord) {
|
||||||
|
(
|
||||||
|
key.to_string(),
|
||||||
|
MetadataRecord {
|
||||||
|
title_key: key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: key.into(),
|
||||||
|
name: key.into(),
|
||||||
|
original_name: None,
|
||||||
|
overview: None,
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Film,
|
||||||
|
poster_url: None,
|
||||||
|
backdrop_url: None,
|
||||||
|
rating: None,
|
||||||
|
genres: genres.iter().map(|genre| genre.to_string()).collect(),
|
||||||
|
episodes: None,
|
||||||
|
url: None,
|
||||||
|
details: Some(details),
|
||||||
|
}),
|
||||||
|
fetched_at: 0,
|
||||||
|
manual: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kon(studio: &str) -> TitleDetails {
|
||||||
|
TitleDetails {
|
||||||
|
directors: vec!["Satoshi Kon".into()],
|
||||||
|
studios: vec![studio.into()],
|
||||||
|
..TitleDetails::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case this exists for: four Satoshi Kon films in one share.
|
||||||
|
#[test]
|
||||||
|
fn a_director_with_enough_titles_gets_a_shelf_ahead_of_a_studio() {
|
||||||
|
let titles: Vec<Title> = [
|
||||||
|
"perfect blue",
|
||||||
|
"millennium actress",
|
||||||
|
"tokyo godfathers",
|
||||||
|
"paprika",
|
||||||
|
]
|
||||||
|
.map(title)
|
||||||
|
.to_vec();
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
record("perfect blue", &["Drama"], kon("Madhouse")),
|
||||||
|
record("millennium actress", &["Drama"], kon("Madhouse")),
|
||||||
|
record("tokyo godfathers", &["Drama"], kon("Madhouse")),
|
||||||
|
record("paprika", &["Sci-Fi"], kon("Madhouse")),
|
||||||
|
]);
|
||||||
|
let shelves = pick(&titles, &metadata, 5);
|
||||||
|
assert_eq!(shelves[0].label, "Directed by Satoshi Kon");
|
||||||
|
assert_eq!(shelves[0].keys.len(), 4);
|
||||||
|
// Madhouse holds exactly the same four, so it is not a second row.
|
||||||
|
assert!(!shelves.iter().any(|shelf| shelf.label == "From Madhouse"));
|
||||||
|
// Drama is three of four: past the share a genre may cover.
|
||||||
|
assert!(!shelves.iter().any(|shelf| shelf.label == "Drama"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Made in Abyss: four titles, one director, one story. Not a shelf.
|
||||||
|
#[test]
|
||||||
|
fn one_franchise_is_not_a_shelf_however_many_titles_it_has() {
|
||||||
|
let titles: Vec<Title> = ["abyss", "film one", "film two", "film three"]
|
||||||
|
.map(title)
|
||||||
|
.to_vec();
|
||||||
|
let kojima = |related: &[&str]| TitleDetails {
|
||||||
|
directors: vec!["Masayuki Kojima".into()],
|
||||||
|
related: related.iter().map(|id| id.to_string()).collect(),
|
||||||
|
..TitleDetails::default()
|
||||||
|
};
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
record(
|
||||||
|
"abyss",
|
||||||
|
&[],
|
||||||
|
kojima(&["film one", "film two", "film three"]),
|
||||||
|
),
|
||||||
|
record("film one", &[], kojima(&["abyss"])),
|
||||||
|
record("film two", &[], kojima(&["abyss"])),
|
||||||
|
record("film three", &[], kojima(&["abyss"])),
|
||||||
|
]);
|
||||||
|
assert!(pick(&titles, &metadata, 5).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_is_airing_comes_first_and_too_few_titles_make_no_shelf() {
|
||||||
|
let titles: Vec<Title> = ["a", "b"].map(title).to_vec();
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
record(
|
||||||
|
"a",
|
||||||
|
&[],
|
||||||
|
TitleDetails {
|
||||||
|
status: Some("RELEASING".into()),
|
||||||
|
studios: vec!["MAPPA".into()],
|
||||||
|
..TitleDetails::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
record("b", &[], TitleDetails::default()),
|
||||||
|
]);
|
||||||
|
let shelves = pick(&titles, &metadata, 5);
|
||||||
|
assert_eq!(shelves.len(), 1);
|
||||||
|
assert_eq!(shelves[0].label, "Airing now");
|
||||||
|
assert_eq!(shelves[0].keys, vec!["a"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,24 @@
|
|||||||
//! also cost them a signup. The trade is coverage — AniList is anime and nothing
|
//! also cost them a signup. The trade is coverage — AniList is anime and nothing
|
||||||
//! else, so a library of films wants [`crate::tmdb`].
|
//! else, so a library of films wants [`crate::tmdb`].
|
||||||
//!
|
//!
|
||||||
//! One query shape is used for everything: a `Page` of `media` matching the
|
//! One query shape is used for every search: a `Page` of `media` matching the
|
||||||
//! search string, with every name AniList knows the show by. The alias list is
|
//! search string, with every name AniList knows the show by. The alias list is
|
||||||
//! the whole reason this works — see [`crate::matching`].
|
//! the whole reason this works — see [`crate::matching`].
|
||||||
|
//!
|
||||||
|
//! AniList identifies a title and keeps nothing worth having about its
|
||||||
|
//! episodes, so enriching a match leans on [`crate::anizip`] for those, walking
|
||||||
|
//! AniList's own `SEQUEL` relations to find every entry the library's seasons
|
||||||
|
//! are spread across.
|
||||||
|
|
||||||
use pstr_core::library::TitleKind;
|
use std::collections::HashSet;
|
||||||
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
|
||||||
|
use pstr_core::library::{self, TitleKind};
|
||||||
|
use pstr_core::metadata::{
|
||||||
|
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::anizip::AniZip;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::limiter::RateLimiter;
|
use crate::limiter::RateLimiter;
|
||||||
use crate::matching::{Candidate, Query};
|
use crate::matching::{Candidate, Query};
|
||||||
@@ -46,52 +56,116 @@ const PAGE_SIZE: u32 = 8;
|
|||||||
/// AniList scores out of 100 and the rest of the app works out of 10.
|
/// AniList scores out of 100 and the rest of the app works out of 10.
|
||||||
const SCORE_SCALE: f32 = 10.0;
|
const SCORE_SCALE: f32 = 10.0;
|
||||||
|
|
||||||
const SEARCH: &str = r#"
|
/// Everything one entry is asked for, by search and by id alike.
|
||||||
|
///
|
||||||
|
/// The relations are the franchise's — see `pstr_core::franchise` — and the
|
||||||
|
/// staff are only the most relevant few, which is where a director sits.
|
||||||
|
macro_rules! entry_fragment {
|
||||||
|
() => {
|
||||||
|
r#"
|
||||||
|
fragment entry on Media {
|
||||||
|
id
|
||||||
|
title { romaji english native }
|
||||||
|
synonyms
|
||||||
|
description(asHtml: false)
|
||||||
|
startDate { year month day }
|
||||||
|
season
|
||||||
|
coverImage { extraLarge large }
|
||||||
|
bannerImage
|
||||||
|
averageScore
|
||||||
|
popularity
|
||||||
|
genres
|
||||||
|
tags { name rank isMediaSpoiler }
|
||||||
|
episodes
|
||||||
|
format
|
||||||
|
status
|
||||||
|
siteUrl
|
||||||
|
studios(isMain: true) { nodes { name } }
|
||||||
|
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
|
||||||
|
nextAiringEpisode { episode airingAt }
|
||||||
|
relations { edges { relationType node { id type } } }
|
||||||
|
}
|
||||||
|
"#
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH: &str = concat!(
|
||||||
|
r#"
|
||||||
query ($search: String, $perPage: Int) {
|
query ($search: String, $perPage: Int) {
|
||||||
Page(page: 1, perPage: $perPage) {
|
Page(page: 1, perPage: $perPage) {
|
||||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
media(search: $search, type: ANIME, sort: SEARCH_MATCH) { ...entry }
|
||||||
id
|
}
|
||||||
title { romaji english native }
|
}
|
||||||
synonyms
|
"#,
|
||||||
description(asHtml: false)
|
entry_fragment!()
|
||||||
startDate { year }
|
);
|
||||||
coverImage { extraLarge large }
|
|
||||||
bannerImage
|
/// One entry by the id a match already found — how a stored match gets the
|
||||||
averageScore
|
/// fields it was stored without, without being searched for and decided
|
||||||
genres
|
/// again.
|
||||||
episodes
|
const BY_ID: &str = concat!(
|
||||||
format
|
r#"
|
||||||
siteUrl
|
query ($id: Int) {
|
||||||
}
|
Media(id: $id, type: ANIME) { ...entry }
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
entry_fragment!()
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The relations that keep to one story. `SPIN_OFF`, `CHARACTER` and `OTHER`
|
||||||
|
/// are left out: they are how one franchise reaches into another, and
|
||||||
|
/// following them merged unrelated shows into one tile.
|
||||||
|
const STORY_RELATIONS: [&str; 8] = [
|
||||||
|
"PREQUEL",
|
||||||
|
"SEQUEL",
|
||||||
|
"PARENT",
|
||||||
|
"SIDE_STORY",
|
||||||
|
"SUMMARY",
|
||||||
|
"ALTERNATIVE",
|
||||||
|
"COMPILATION",
|
||||||
|
"CONTAINS",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A tag ranked below this is AniList's community being unsure of it.
|
||||||
|
const MIN_TAG_RANK: u32 = 60;
|
||||||
|
|
||||||
|
/// How many tags to keep: the rest are increasingly specific trivia.
|
||||||
|
const MAX_TAGS: usize = 8;
|
||||||
|
|
||||||
|
/// The entries an entry leads on to.
|
||||||
|
///
|
||||||
|
/// One hop at a time rather than a nested query several levels deep: most
|
||||||
|
/// titles need no hop at all, and the ones that do stop as soon as the
|
||||||
|
/// library's files are answered.
|
||||||
|
const RELATIONS: &str = r#"
|
||||||
|
query ($id: Int) {
|
||||||
|
Media(id: $id, type: ANIME) {
|
||||||
|
relations { edges { relationType node { id type format } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Episode titles, such as AniList has them.
|
/// How many entries of one franchise to read before giving up on files that
|
||||||
|
/// still have no answer.
|
||||||
///
|
///
|
||||||
/// `streamingEpisodes` is the only place AniList keeps them — there is no
|
/// Attack on Titan's four seasons are seven entries and Bleach's seventeen are
|
||||||
/// per-episode overview in its API at all — and the entries are what the
|
/// five, so twelve covers every long run in a real library while bounding what
|
||||||
/// streaming sites published: `"Episode 57 - The Immortal Legion"`, in airing
|
/// a file no entry will ever answer — a special, a recap — can cost.
|
||||||
/// order, occasionally with a gap. So the number is read out of the title where
|
const MAX_CHAIN_ENTRIES: usize = 12;
|
||||||
/// it says one, and only falls back to the position in the list.
|
|
||||||
const EPISODES: &str = r#"
|
|
||||||
query ($id: Int) {
|
|
||||||
Media(id: $id, type: ANIME) {
|
|
||||||
episodes
|
|
||||||
streamingEpisodes { title thumbnail }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
pub struct AniList {
|
pub struct AniList {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
/// Shared by every lookup in flight — see [`crate::limiter`].
|
/// Shared by every lookup in flight — see [`crate::limiter`].
|
||||||
limiter: RateLimiter,
|
limiter: RateLimiter,
|
||||||
|
anizip: AniZip,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AniList {
|
impl AniList {
|
||||||
pub fn new(http: reqwest::Client) -> Self {
|
/// `language` is the one episode names are wanted in, where ani.zip has
|
||||||
|
/// them in it.
|
||||||
|
pub fn new(http: reqwest::Client, language: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
anizip: AniZip::new(http.clone(), language),
|
||||||
http,
|
http,
|
||||||
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||||
}
|
}
|
||||||
@@ -103,11 +177,6 @@ impl Provider for AniList {
|
|||||||
ProviderId::AniList
|
ProviderId::AniList
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sequel is its own entry here, numbering its episodes from one.
|
|
||||||
fn seasons_are_separate_entries(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
||||||
// Each term is asked only because the one before it came back with
|
// Each term is asked only because the one before it came back with
|
||||||
// nothing at all — see `search_terms`. A page of candidates, however
|
// nothing at all — see `search_terms`. A page of candidates, however
|
||||||
@@ -133,29 +202,113 @@ impl Provider for AniList {
|
|||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
|
/// The episodes of every entry the library's files are spread across, and
|
||||||
|
/// the series' fanart.
|
||||||
|
///
|
||||||
|
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
|
||||||
|
/// four of them, Mushoku Tensei's first is two — so a title's match is
|
||||||
|
/// only where its episodes *start*. From there the `SEQUEL` relations are
|
||||||
|
/// followed one entry at a time, each entry's episodes taken from ani.zip,
|
||||||
|
/// until every numbered file in `title` has an answer, the franchise ends,
|
||||||
|
/// or [`MAX_CHAIN_ENTRIES`] is reached. A one-season show is one ani.zip
|
||||||
|
/// request and no relations query at all.
|
||||||
|
///
|
||||||
|
/// Following relations rather than searching for `<title> 2nd Season` is
|
||||||
|
/// the point: that search found the base entry again for Attack on Titan,
|
||||||
|
/// whose seasons two to four were then captioned with season one's names.
|
||||||
|
async fn enrich(&self, title: &library::Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
// The id came from our own search response, so a non-numeric one is a
|
// The id came from our own search response, so a non-numeric one is a
|
||||||
// record written by some other version — not worth an error, and there
|
// record written by some other version — not worth an error, and there
|
||||||
// is nothing to ask about.
|
// is nothing to ask about.
|
||||||
let Ok(id) = title.remote_id.parse::<i64>() else {
|
let Ok(first) = found.remote_id.parse::<i64>() else {
|
||||||
return Ok(Vec::new());
|
return Ok(Enrichment::default());
|
||||||
};
|
};
|
||||||
|
let wanted: Vec<(Option<u32>, u32)> = title
|
||||||
|
.episodes()
|
||||||
|
.filter_map(|episode| Some((episode.node.parsed.season, episode.node.parsed.episode?)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let data: Option<MediaData> = self
|
let mut enrichment = Enrichment::default();
|
||||||
.query(serde_json::json!({
|
let mut taken = HashSet::new();
|
||||||
"query": EPISODES,
|
let mut visited = HashSet::new();
|
||||||
"variables": { "id": id },
|
// The same ids as `visited`, in the order they were read.
|
||||||
}))
|
let mut chain = Vec::new();
|
||||||
.await?;
|
let mut series_entries = 0;
|
||||||
|
let mut next = Some(Link {
|
||||||
|
id: first,
|
||||||
|
series: found.kind == TitleKind::Series,
|
||||||
|
});
|
||||||
|
while let Some(link) = next.take() {
|
||||||
|
visited.insert(link.id);
|
||||||
|
chain.push(link.id.to_string());
|
||||||
|
let entry_season = link.series.then(|| {
|
||||||
|
series_entries += 1;
|
||||||
|
series_entries
|
||||||
|
});
|
||||||
|
let mapping = self.anizip.mapping(link.id, entry_season).await?;
|
||||||
|
if enrichment.backdrop_url.is_none() {
|
||||||
|
enrichment.backdrop_url = mapping.fanart;
|
||||||
|
}
|
||||||
|
// Where two entries both list an episode, the earlier one — the
|
||||||
|
// one closer to the match — keeps it.
|
||||||
|
enrichment.episodes.extend(
|
||||||
|
mapping
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|episode| taken.insert((episode.season, episode.number))),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(data
|
if answers_all(&enrichment.episodes, &wanted) || visited.len() >= MAX_CHAIN_ENTRIES {
|
||||||
.and_then(|data| data.media)
|
break;
|
||||||
.map(|media| media.into_episodes())
|
}
|
||||||
.unwrap_or_default())
|
next = self.sequel(link.id, &visited).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"{}: {} episodes over {} entries",
|
||||||
|
found.name,
|
||||||
|
enrichment.episodes.len(),
|
||||||
|
visited.len()
|
||||||
|
);
|
||||||
|
enrichment.chain = chain;
|
||||||
|
Ok(enrichment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AniList {
|
impl AniList {
|
||||||
|
/// The entry the AniList id `id` names, as search would have found it.
|
||||||
|
/// `None` for an id AniList no longer has.
|
||||||
|
pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
|
||||||
|
let Ok(id) = id.parse::<i64>() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let data: Option<EntryData> = self
|
||||||
|
.query(serde_json::json!({
|
||||||
|
"query": BY_ID,
|
||||||
|
"variables": { "id": id },
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
Ok(data
|
||||||
|
.and_then(|data| data.media)
|
||||||
|
.map(|media| media.into_candidate().metadata))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entry `id` continues into, if any.
|
||||||
|
async fn sequel(&self, id: i64, visited: &HashSet<i64>) -> Result<Option<Link>> {
|
||||||
|
let data: Option<RelationsData> = self
|
||||||
|
.query(serde_json::json!({
|
||||||
|
"query": RELATIONS,
|
||||||
|
"variables": { "id": id },
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
let edges = data
|
||||||
|
.and_then(|data| data.media)
|
||||||
|
.map(|media| media.relations.edges)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(next_in_chain(edges, visited))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One GraphQL requestimpl AniList {
|
||||||
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
||||||
/// status and an `errors` array under a 200 — folded into one.
|
/// status and an `errors` array under a 200 — folded into one.
|
||||||
///
|
///
|
||||||
@@ -278,78 +431,78 @@ struct SearchData {
|
|||||||
page: Page,
|
page: Page,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
/// One step along a franchise.
|
||||||
struct MediaData {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[serde(rename = "Media")]
|
struct Link {
|
||||||
media: Option<EpisodeMedia>,
|
id: i64,
|
||||||
|
/// Whether the entry is part of the series proper — TV, a short or a web
|
||||||
|
/// series — rather than a film or a special, which no release counts as a
|
||||||
|
/// season.
|
||||||
|
series: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
/// Which of an entry's relations the franchise continues through.
|
||||||
struct EpisodeMedia {
|
|
||||||
#[serde(rename = "streamingEpisodes", default)]
|
|
||||||
streaming_episodes: Vec<StreamingEpisode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct StreamingEpisode {
|
|
||||||
title: Option<String>,
|
|
||||||
thumbnail: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EpisodeMedia {
|
|
||||||
fn into_episodes(self) -> Vec<EpisodeMetadata> {
|
|
||||||
self.streaming_episodes
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, episode)| {
|
|
||||||
let raw = episode.title.unwrap_or_default();
|
|
||||||
let (number, name) = split_numbering(&raw);
|
|
||||||
EpisodeMetadata {
|
|
||||||
// AniList counts straight through, with no seasons: a
|
|
||||||
// sequel is a separate entry, not a second season.
|
|
||||||
season: None,
|
|
||||||
// The position is the fallback and not the answer: the
|
|
||||||
// lists have gaps, and an off-by-one here would caption
|
|
||||||
// every episode of a show with the next one's name.
|
|
||||||
number: number.unwrap_or(index as u32 + 1),
|
|
||||||
name: name.filter(|name| !name.is_empty()),
|
|
||||||
// AniList has no per-episode synopsis to give.
|
|
||||||
overview: None,
|
|
||||||
still_url: episode.thumbnail,
|
|
||||||
air_date: None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split `"Episode 57 - The Immortal Legion"` into its number and its name.
|
|
||||||
///
|
///
|
||||||
/// Both parts are optional: plenty of entries are just `"Episode 12"`, and a
|
/// A sequel that is itself a series wins over one that is a film or a
|
||||||
/// few are only a name.
|
/// special, but a special is still followed when it is the only way on:
|
||||||
fn split_numbering(title: &str) -> (Option<u32>, Option<String>) {
|
/// Mushishi's first series leads to its second only through an OVA.
|
||||||
let trimmed = title.trim();
|
fn next_in_chain(edges: Vec<RelationEdge>, visited: &HashSet<i64>) -> Option<Link> {
|
||||||
let rest = trimmed
|
edges
|
||||||
.strip_prefix("Episode ")
|
.into_iter()
|
||||||
.or_else(|| trimmed.strip_prefix("episode "))
|
.filter(|edge| edge.relation_type.as_deref() == Some("SEQUEL"))
|
||||||
.or_else(|| trimmed.strip_prefix("EP"))
|
.filter_map(|edge| edge.node)
|
||||||
.or_else(|| trimmed.strip_prefix("E"));
|
// A sequel can be the manga it was adapted into; only anime has
|
||||||
|
// episodes to number.
|
||||||
|
.filter(|node| node.kind.as_deref() == Some("ANIME") && !visited.contains(&node.id))
|
||||||
|
.map(|node| Link {
|
||||||
|
id: node.id,
|
||||||
|
series: is_series_format(node.format.as_deref()),
|
||||||
|
})
|
||||||
|
.min_by_key(|link| !link.series)
|
||||||
|
}
|
||||||
|
|
||||||
let Some(rest) = rest else {
|
fn is_series_format(format: Option<&str>) -> bool {
|
||||||
return (None, Some(trimmed.to_string()).filter(|t| !t.is_empty()));
|
matches!(format, Some("TV" | "TV_SHORT" | "ONA"))
|
||||||
};
|
}
|
||||||
|
|
||||||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
/// Whether every numbered file has an answer in `episodes`.
|
||||||
if digits.is_empty() {
|
fn answers_all(episodes: &[EpisodeMetadata], wanted: &[(Option<u32>, u32)]) -> bool {
|
||||||
return (None, Some(trimmed.to_string()));
|
let guide = EpisodeGuide::new(episodes.to_vec());
|
||||||
}
|
wanted
|
||||||
|
.iter()
|
||||||
|
.all(|&(season, number)| guide.get(season, number).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
let name = rest[digits.len()..]
|
#[derive(Deserialize)]
|
||||||
.trim_start()
|
struct RelationsData {
|
||||||
.trim_start_matches(['-', '–', ':'])
|
#[serde(rename = "Media")]
|
||||||
.trim()
|
media: Option<RelationsMedia>,
|
||||||
.to_string();
|
}
|
||||||
(digits.parse().ok(), Some(name).filter(|n| !n.is_empty()))
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RelationsMedia {
|
||||||
|
relations: Relations,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Relations {
|
||||||
|
#[serde(default)]
|
||||||
|
edges: Vec<RelationEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RelationEdge {
|
||||||
|
#[serde(rename = "relationType")]
|
||||||
|
relation_type: Option<String>,
|
||||||
|
node: Option<RelationNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RelationNode {
|
||||||
|
id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: Option<String>,
|
||||||
|
format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -373,12 +526,76 @@ struct Media {
|
|||||||
banner_image: Option<String>,
|
banner_image: Option<String>,
|
||||||
#[serde(rename = "averageScore")]
|
#[serde(rename = "averageScore")]
|
||||||
average_score: Option<f32>,
|
average_score: Option<f32>,
|
||||||
|
popularity: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
genres: Vec<String>,
|
genres: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Vec<Tag>,
|
||||||
episodes: Option<u32>,
|
episodes: Option<u32>,
|
||||||
format: Option<String>,
|
format: Option<String>,
|
||||||
|
season: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
#[serde(rename = "siteUrl")]
|
#[serde(rename = "siteUrl")]
|
||||||
site_url: Option<String>,
|
site_url: Option<String>,
|
||||||
|
studios: Option<Studios>,
|
||||||
|
staff: Option<Staff>,
|
||||||
|
#[serde(rename = "nextAiringEpisode")]
|
||||||
|
next_airing_episode: Option<NextAiring>,
|
||||||
|
relations: Option<Relations>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EntryData {
|
||||||
|
#[serde(rename = "Media")]
|
||||||
|
media: Option<Media>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Tag {
|
||||||
|
name: String,
|
||||||
|
rank: Option<u32>,
|
||||||
|
#[serde(rename = "isMediaSpoiler", default)]
|
||||||
|
spoiler: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Studios {
|
||||||
|
#[serde(default)]
|
||||||
|
nodes: Vec<Named>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Named {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Staff {
|
||||||
|
#[serde(default)]
|
||||||
|
edges: Vec<StaffEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StaffEdge {
|
||||||
|
role: Option<String>,
|
||||||
|
node: Option<Person>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Person {
|
||||||
|
name: PersonName,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PersonName {
|
||||||
|
full: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NextAiring {
|
||||||
|
episode: Option<u32>,
|
||||||
|
#[serde(rename = "airingAt")]
|
||||||
|
airing_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -391,6 +608,20 @@ struct Title {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FuzzyDate {
|
struct FuzzyDate {
|
||||||
year: Option<u32>,
|
year: Option<u32>,
|
||||||
|
month: Option<u32>,
|
||||||
|
day: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FuzzyDate {
|
||||||
|
/// `2013-04-07`, `2013-04` or `2013` — as much as AniList knows.
|
||||||
|
fn text(&self) -> Option<String> {
|
||||||
|
let year = self.year?;
|
||||||
|
Some(match (self.month, self.day) {
|
||||||
|
(Some(month), Some(day)) => format!("{year:04}-{month:02}-{day:02}"),
|
||||||
|
(Some(month), None) => format!("{year:04}-{month:02}"),
|
||||||
|
_ => format!("{year:04}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -434,6 +665,72 @@ impl Media {
|
|||||||
.cover_image
|
.cover_image
|
||||||
.and_then(|cover| cover.extra_large.or(cover.large));
|
.and_then(|cover| cover.extra_large.or(cover.large));
|
||||||
|
|
||||||
|
let details = TitleDetails {
|
||||||
|
romaji: self.title.romaji.clone(),
|
||||||
|
banner_url: self.banner_image,
|
||||||
|
format: self.format.clone(),
|
||||||
|
season: self.season,
|
||||||
|
start_date: self.start_date.as_ref().and_then(FuzzyDate::text),
|
||||||
|
studios: self
|
||||||
|
.studios
|
||||||
|
.map(|studios| {
|
||||||
|
studios
|
||||||
|
.nodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|studio| studio.name)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
directors: self
|
||||||
|
.staff
|
||||||
|
.map(|staff| {
|
||||||
|
staff
|
||||||
|
.edges
|
||||||
|
.into_iter()
|
||||||
|
// `Director`, and not `Assistant Director`,
|
||||||
|
// `Episode Director` or `Director (eps 3, 7)` — those
|
||||||
|
// are many people per show, and a shelf of one
|
||||||
|
// storyboard artist's episodes is not what a
|
||||||
|
// "directed by" row means.
|
||||||
|
.filter(|edge| edge.role.as_deref() == Some("Director"))
|
||||||
|
.filter_map(|edge| edge.node.and_then(|person| person.name.full))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
tags: self
|
||||||
|
.tags
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tag| !tag.spoiler && tag.rank.unwrap_or(0) >= MIN_TAG_RANK)
|
||||||
|
.take(MAX_TAGS)
|
||||||
|
.map(|tag| tag.name)
|
||||||
|
.collect(),
|
||||||
|
popularity: self.popularity,
|
||||||
|
status: self.status,
|
||||||
|
next_episode: self
|
||||||
|
.next_airing_episode
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|next| next.episode),
|
||||||
|
next_airing_at: self.next_airing_episode.and_then(|next| next.airing_at),
|
||||||
|
related: self
|
||||||
|
.relations
|
||||||
|
.map(|relations| {
|
||||||
|
relations
|
||||||
|
.edges
|
||||||
|
.into_iter()
|
||||||
|
.filter(|edge| {
|
||||||
|
edge.relation_type
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|kind| STORY_RELATIONS.contains(&kind))
|
||||||
|
})
|
||||||
|
.filter_map(|edge| edge.node)
|
||||||
|
.filter(|node| node.kind.as_deref() == Some("ANIME"))
|
||||||
|
.map(|node| node.id.to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
chain: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
Candidate {
|
Candidate {
|
||||||
metadata: TitleMetadata {
|
metadata: TitleMetadata {
|
||||||
provider: ProviderId::AniList,
|
provider: ProviderId::AniList,
|
||||||
@@ -444,13 +741,16 @@ impl Media {
|
|||||||
year: self.start_date.and_then(|date| date.year),
|
year: self.start_date.and_then(|date| date.year),
|
||||||
kind: kind_of(self.format.as_deref()),
|
kind: kind_of(self.format.as_deref()),
|
||||||
poster_url: cover,
|
poster_url: cover,
|
||||||
// Usually `null`. AniList only has banners for the better-known
|
// Not `bannerImage`: that is a 4.75:1 strip, and drawn into a
|
||||||
// shows, which is why a tile has to cope with a poster's shape.
|
// 16:9 tile it is a blurred sliver of its own middle. The
|
||||||
backdrop_url: self.banner_image,
|
// backdrop is ani.zip's fanart, found when the match is
|
||||||
|
// enriched.
|
||||||
|
backdrop_url: None,
|
||||||
rating: self.average_score.map(|score| score / SCORE_SCALE),
|
rating: self.average_score.map(|score| score / SCORE_SCALE),
|
||||||
genres: self.genres,
|
genres: self.genres,
|
||||||
episodes: self.episodes,
|
episodes: self.episodes,
|
||||||
url: self.site_url,
|
url: self.site_url,
|
||||||
|
details: Some(details),
|
||||||
},
|
},
|
||||||
aliases,
|
aliases,
|
||||||
// Null on anything AniList has not seen air yet, which is not the
|
// Null on anything AniList has not seen air yet, which is not the
|
||||||
@@ -594,60 +894,95 @@ mod tests {
|
|||||||
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
|
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
|
||||||
fn an_episode_title_gives_up_its_number_and_its_name() {
|
RelationEdge {
|
||||||
assert_eq!(
|
relation_type: Some(relation.into()),
|
||||||
split_numbering("Episode 57 - The Immortal Legion"),
|
node: Some(RelationNode {
|
||||||
(Some(57), Some("The Immortal Legion".to_string()))
|
id,
|
||||||
);
|
kind: Some(kind.into()),
|
||||||
assert_eq!(split_numbering("Episode 12"), (Some(12), None));
|
format: Some(format.into()),
|
||||||
// Some entries are only a name, and some sites write it differently.
|
}),
|
||||||
assert_eq!(
|
}
|
||||||
split_numbering("The Day of the Beginning"),
|
|
||||||
(None, Some("The Day of the Beginning".to_string()))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
split_numbering("E3 – Whatever"),
|
|
||||||
(Some(3), Some("Whatever".to_string()))
|
|
||||||
);
|
|
||||||
assert_eq!(split_numbering(""), (None, None));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attack on Titan's relations: an adaptation, two compilation films, a
|
||||||
|
/// prequel OVA and a spin-off around the one sequel that is the next season.
|
||||||
#[test]
|
#[test]
|
||||||
fn episode_numbers_come_from_the_titles_and_not_from_the_order() {
|
fn the_chain_continues_through_the_sequel_and_nothing_else() {
|
||||||
// AniList lists what streaming sites published, and those lists have
|
let edges = vec![
|
||||||
// gaps. Numbering by position would caption episode 4 as episode 3.
|
edge("ADAPTATION", 53390, "MANGA", "MANGA"),
|
||||||
let media: EpisodeMedia = serde_json::from_str(
|
edge("ALTERNATIVE", 20691, "ANIME", "MOVIE"),
|
||||||
r#"{"streamingEpisodes": [
|
edge("PREQUEL", 20811, "ANIME", "OVA"),
|
||||||
{"title": "Episode 1 - Fullmetal Alchemist", "thumbnail": "one.jpg"},
|
edge("SPIN_OFF", 21281, "ANIME", "TV"),
|
||||||
{"title": "Episode 3 - City of Heresy", "thumbnail": null}
|
edge("SEQUEL", 20958, "ANIME", "TV"),
|
||||||
]}"#,
|
];
|
||||||
)
|
assert_eq!(
|
||||||
.expect("parse");
|
next_in_chain(edges, &HashSet::new()),
|
||||||
|
Some(Link {
|
||||||
let episodes = media.into_episodes();
|
id: 20958,
|
||||||
assert_eq!(episodes[0].number, 1);
|
series: true
|
||||||
assert_eq!(episodes[0].name.as_deref(), Some("Fullmetal Alchemist"));
|
})
|
||||||
assert_eq!(episodes[0].still_url.as_deref(), Some("one.jpg"));
|
);
|
||||||
assert_eq!(episodes[1].number, 3);
|
|
||||||
// AniList counts straight through; nothing here is a season.
|
|
||||||
assert!(episodes.iter().all(|episode| episode.season.is_none()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mushishi's first series leads on only through an OVA, which is followed
|
||||||
|
/// — but not counted as a season of the series.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_untitled_list_still_numbers_its_episodes() {
|
fn a_special_is_followed_when_it_is_the_only_way_on() {
|
||||||
let media: EpisodeMedia = serde_json::from_str(
|
let edges = vec![edge("SEQUEL", 20526, "ANIME", "SPECIAL")];
|
||||||
r#"{"streamingEpisodes": [{"title": null, "thumbnail": null},
|
assert_eq!(
|
||||||
{"title": "", "thumbnail": null}]}"#,
|
next_in_chain(edges, &HashSet::new()),
|
||||||
)
|
Some(Link {
|
||||||
.expect("parse");
|
id: 20526,
|
||||||
let episodes = media.into_episodes();
|
series: false
|
||||||
assert_eq!(episodes[0].number, 1);
|
})
|
||||||
assert_eq!(episodes[1].number, 2);
|
);
|
||||||
assert!(episodes.iter().all(|episode| episode.name.is_none()));
|
// With a series beside it, the series wins.
|
||||||
|
let edges = vec![
|
||||||
|
edge("SEQUEL", 20752, "ANIME", "SPECIAL"),
|
||||||
|
edge("SEQUEL", 20751, "ANIME", "TV"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
next_in_chain(edges, &HashSet::new()).map(|link| link.id),
|
||||||
|
Some(20751)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Missing fields are the normal case for a recently added entry, and must
|
/// A franchise whose relations loop must end rather than walk forever.
|
||||||
|
#[test]
|
||||||
|
fn an_entry_already_read_is_not_read_again() {
|
||||||
|
let edges = vec![edge("SEQUEL", 1, "ANIME", "TV")];
|
||||||
|
assert_eq!(next_in_chain(edges, &HashSet::from([1])), None);
|
||||||
|
let manga = vec![edge("SEQUEL", 2, "MANGA", "MANGA")];
|
||||||
|
assert_eq!(next_in_chain(manga, &HashSet::new()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk stops once every file is answered and not before: a season
|
||||||
|
/// folder of absolute numbers counts as answered by its own season.
|
||||||
|
#[test]
|
||||||
|
fn the_walk_stops_when_every_numbered_file_has_an_answer() {
|
||||||
|
let episode = |season, number, absolute| EpisodeMetadata {
|
||||||
|
season: Some(season),
|
||||||
|
number,
|
||||||
|
absolute: Some(absolute),
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
|
name: None,
|
||||||
|
overview: None,
|
||||||
|
still_url: None,
|
||||||
|
air_date: None,
|
||||||
|
};
|
||||||
|
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
|
||||||
|
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
|
||||||
|
assert!(!answers_all(&season_one, &[(Some(1), 1), (Some(2), 26)]));
|
||||||
|
|
||||||
|
let both = vec![episode(1, 1, 1), episode(2, 1, 26)];
|
||||||
|
assert!(answers_all(&both, &[(Some(1), 1), (Some(2), 26)]));
|
||||||
|
// A film: nothing numbered, nothing to walk for.
|
||||||
|
assert!(answers_all(&[], &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Missing fields are the normal case /// Missing fields are the normal case for a recently added entry, and must
|
||||||
/// not fail the whole page of results.
|
/// not fail the whole page of results.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_entry_with_almost_nothing_in_it_still_parses() {
|
fn an_entry_with_almost_nothing_in_it_still_parses() {
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! ani.zip: what AniList does not say about one of its own entries.
|
||||||
|
//!
|
||||||
|
//! AniList is where a title is *identified* — its search and its aliases are
|
||||||
|
//! what make a romaji folder name match — but it keeps no episode list worth
|
||||||
|
//! the name and no landscape art. ani.zip answers both, keyed by the AniList id
|
||||||
|
//! the match already produced, so asking it sends nothing about the library the
|
||||||
|
//! match did not already send. No key and no account, like AniList.
|
||||||
|
//!
|
||||||
|
//! The episode list is TVDB's, and that is the point of it: release groups
|
||||||
|
//! number their files the way TVDB does — `Bleach - S17E01` is TVDB's season
|
||||||
|
//! 17, not AniList's — and every episode here carries TVDB's season, number and
|
||||||
|
//! absolute number together. See `docs/METADATA.md`.
|
||||||
|
//!
|
||||||
|
//! It is a community service rather than an institution, so everything it says
|
||||||
|
//! is stored, and an entry it does not know (`404`) is an empty answer rather
|
||||||
|
//! than a failure.
|
||||||
|
|
||||||
|
use pstr_core::metadata::EpisodeMetadata;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::limiter::RateLimiter;
|
||||||
|
|
||||||
|
const ENDPOINT: &str = "https://api.ani.zip/mappings";
|
||||||
|
|
||||||
|
/// ani.zip publishes no limit. One a second is far below anything a CDN-cached
|
||||||
|
/// JSON endpoint notices, and a library's enrichment is a one-off burst.
|
||||||
|
const REQUESTS_PER_MINUTE: u32 = 60;
|
||||||
|
|
||||||
|
/// TVDB's own placeholder for an episode it has not named yet.
|
||||||
|
const UNNAMED: &str = "TBA";
|
||||||
|
|
||||||
|
/// What ani.zip knows about one AniList entry.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Mapping {
|
||||||
|
pub episodes: Vec<EpisodeMetadata>,
|
||||||
|
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
|
||||||
|
pub fanart: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AniZip {
|
||||||
|
http: reqwest::Client,
|
||||||
|
/// The episode-title language tried first; English, then romaji, then
|
||||||
|
/// Japanese after it.
|
||||||
|
language: String,
|
||||||
|
limiter: RateLimiter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AniZip {
|
||||||
|
pub fn new(http: reqwest::Client, language: String) -> Self {
|
||||||
|
Self {
|
||||||
|
http,
|
||||||
|
language,
|
||||||
|
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything ani.zip has for the AniList entry `anilist_id`.
|
||||||
|
///
|
||||||
|
/// `entry_season` is where this entry sits among the franchise's series
|
||||||
|
/// entries — see [`EpisodeMetadata::entry_season`] — and `None` for a film
|
||||||
|
/// or a special, whose episodes no release numbers that way.
|
||||||
|
pub async fn mapping(&self, anilist_id: i64, entry_season: Option<u32>) -> Result<Mapping> {
|
||||||
|
self.limiter.acquire().await;
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(ENDPOINT)
|
||||||
|
.query(&[("anilist_id", anilist_id)])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
if status.as_u16() == 404 {
|
||||||
|
return Ok(Mapping::default());
|
||||||
|
}
|
||||||
|
if status.as_u16() == 429 {
|
||||||
|
self.limiter.throttled(response.headers()).await;
|
||||||
|
return Err(Error::RateLimited);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::Http(format!("ani.zip answered {status}")));
|
||||||
|
}
|
||||||
|
let payload: Payload = response.json().await?;
|
||||||
|
Ok(payload.into_mapping(&self.language, entry_season))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct Payload {
|
||||||
|
#[serde(default)]
|
||||||
|
episodes: std::collections::BTreeMap<String, Episode>,
|
||||||
|
#[serde(default)]
|
||||||
|
images: Vec<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Episode {
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
season_number: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
episode_number: Option<u32>,
|
||||||
|
#[serde(default, deserialize_with = "lenient_number")]
|
||||||
|
absolute_episode_number: Option<u32>,
|
||||||
|
title: Option<std::collections::HashMap<String, Option<String>>>,
|
||||||
|
/// TVDB's synopsis.
|
||||||
|
overview: Option<String>,
|
||||||
|
/// AniDB's, which is there when TVDB's is not.
|
||||||
|
summary: Option<String>,
|
||||||
|
image: Option<String>,
|
||||||
|
air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Image {
|
||||||
|
cover_type: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Payload {
|
||||||
|
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
|
||||||
|
let fanart = self
|
||||||
|
.images
|
||||||
|
.into_iter()
|
||||||
|
.find(|image| image.cover_type.as_deref() == Some("Fanart"))
|
||||||
|
.and_then(|image| image.url);
|
||||||
|
|
||||||
|
let episodes = self
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(key, episode)| {
|
||||||
|
// An episode TVDB has not placed has nothing a filename could
|
||||||
|
// be matched against — a film's own entry is the usual case.
|
||||||
|
let season = episode.season_number?;
|
||||||
|
let number = episode.episode_number?;
|
||||||
|
// Keys are the entry's own numbering: `"1"`, `"2"` for its
|
||||||
|
// episodes and `"S1"` for its specials, which no release counts
|
||||||
|
// as part of the season.
|
||||||
|
let entry_number = entry_season.and(key.parse::<u32>().ok());
|
||||||
|
Some(EpisodeMetadata {
|
||||||
|
season: Some(season),
|
||||||
|
number,
|
||||||
|
absolute: episode.absolute_episode_number,
|
||||||
|
entry_season: entry_number.and(entry_season),
|
||||||
|
entry_number,
|
||||||
|
name: episode
|
||||||
|
.title
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|titles| title_in(titles, language)),
|
||||||
|
overview: text(episode.overview).or_else(|| text(episode.summary)),
|
||||||
|
still_url: text(episode.image),
|
||||||
|
air_date: text(episode.air_date),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Mapping { episodes, fanart }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The episode's name in `language`, else English, romaji or Japanese.
|
||||||
|
fn title_in(
|
||||||
|
titles: &std::collections::HashMap<String, Option<String>>,
|
||||||
|
language: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
[language, "en", "x-jat", "ja"]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|language| titles.get(language).cloned().flatten())
|
||||||
|
.map(|title| title.trim().to_string())
|
||||||
|
.find(|title| !title.is_empty() && title != UNNAMED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A number, whether it arrives as one or as a string of digits.
|
||||||
|
///
|
||||||
|
/// ani.zip writes its regular episodes' numbers as JSON numbers and its
|
||||||
|
/// specials' as strings; one strict field would fail the whole entry over the
|
||||||
|
/// specials.
|
||||||
|
fn lenient_number<'de, D>(deserializer: D) -> std::result::Result<Option<u32>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(match serde_json::Value::deserialize(deserializer)? {
|
||||||
|
serde_json::Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
|
||||||
|
serde_json::Value::String(text) => text.trim().parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Attack on Titan Season 2 as ani.zip answers it, trimmed: a regular
|
||||||
|
/// episode numbered in numbers, a special numbered in strings, a film-like
|
||||||
|
/// entry TVDB never placed, and the series' art.
|
||||||
|
const SEASON_TWO: &str = r#"{
|
||||||
|
"episodes": {
|
||||||
|
"1": {"seasonNumber": 2, "episodeNumber": 1, "absoluteEpisodeNumber": 26,
|
||||||
|
"episode": "1",
|
||||||
|
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
|
||||||
|
"overview": "Titans appear inside Wall Rose.",
|
||||||
|
"image": "https://artworks.thetvdb.com/still.jpg",
|
||||||
|
"airDate": "2017-04-01"},
|
||||||
|
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
|
||||||
|
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
|
||||||
|
"summary": "An OVA."},
|
||||||
|
"2": {"episode": "2", "title": {"en": "Unplaced"}}
|
||||||
|
},
|
||||||
|
"images": [
|
||||||
|
{"coverType": "Banner", "url": "banner.jpg"},
|
||||||
|
{"coverType": "Fanart", "url": "fanart.jpg"}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
fn mapping(entry_season: Option<u32>) -> Mapping {
|
||||||
|
serde_json::from_str::<Payload>(SEASON_TWO)
|
||||||
|
.expect("parse")
|
||||||
|
.into_mapping("en", entry_season)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_episode_carries_tvdbs_numbering_and_its_entrys() {
|
||||||
|
let mapping = mapping(Some(2));
|
||||||
|
let first = mapping
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.find(|episode| episode.season == Some(2))
|
||||||
|
.expect("the regular episode");
|
||||||
|
assert_eq!(first.number, 1);
|
||||||
|
assert_eq!(first.absolute, Some(26));
|
||||||
|
assert_eq!((first.entry_season, first.entry_number), (Some(2), Some(1)));
|
||||||
|
assert_eq!(first.name.as_deref(), Some("Beast Titan"));
|
||||||
|
assert_eq!(
|
||||||
|
first.overview.as_deref(),
|
||||||
|
Some("Titans appear inside Wall Rose.")
|
||||||
|
);
|
||||||
|
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A special is numbered in strings and filed under season zero, and no
|
||||||
|
/// release counts it as an episode of the entry it came with.
|
||||||
|
#[test]
|
||||||
|
fn a_special_keeps_its_tvdb_place_and_takes_no_entry_number() {
|
||||||
|
let mapping = mapping(Some(2));
|
||||||
|
let special = mapping
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.find(|episode| episode.season == Some(0))
|
||||||
|
.expect("the special");
|
||||||
|
assert_eq!(special.number, 7);
|
||||||
|
assert_eq!(special.entry_number, None);
|
||||||
|
assert_eq!(special.entry_season, None);
|
||||||
|
// `TBA` is TVDB saying it has no name yet, so the next language answers.
|
||||||
|
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
|
||||||
|
// AniDB's summary stands in for TVDB's missing overview.
|
||||||
|
assert_eq!(special.overview.as_deref(), Some("An OVA."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_episode_tvdb_never_placed_is_left_out() {
|
||||||
|
assert_eq!(mapping(Some(2)).episodes.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A film or a special as an entry has no place among the series' seasons.
|
||||||
|
#[test]
|
||||||
|
fn an_entry_outside_the_series_numbers_nothing_by_entry() {
|
||||||
|
assert!(
|
||||||
|
mapping(None)
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.all(|episode| episode.entry_season.is_none() && episode.entry_number.is_none())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 16:9 picture, not the banner — the banner is the 4.75:1 strip that
|
||||||
|
/// looked wrong on every desktop tile.
|
||||||
|
#[test]
|
||||||
|
fn the_fanart_is_taken_and_the_banner_is_not() {
|
||||||
|
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
|
||||||
|
let mapping = serde_json::from_str::<Payload>("{}")
|
||||||
|
.expect("parse")
|
||||||
|
.into_mapping("en", Some(1));
|
||||||
|
assert!(mapping.episodes.is_empty());
|
||||||
|
assert_eq!(mapping.fanart, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
|
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
|
||||||
//! (anilist | tmdb) │
|
//! (anilist | tmdb) │
|
||||||
//! ▼
|
//! ▼
|
||||||
//! TitleMetadata
|
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
|
||||||
|
//! (episodes, fanart) (anilist relations + ani.zip | tmdb)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! Three things are worth knowing before touching any of it:
|
//! Three things are worth knowing before touching any of it:
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
//! without either depending on this crate.
|
//! without either depending on this crate.
|
||||||
|
|
||||||
pub mod anilist;
|
pub mod anilist;
|
||||||
|
pub mod anizip;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod matching;
|
pub mod matching;
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ mod tests {
|
|||||||
genres: Vec::new(),
|
genres: Vec::new(),
|
||||||
episodes: None,
|
episodes: None,
|
||||||
url: None,
|
url: None,
|
||||||
|
details: None,
|
||||||
},
|
},
|
||||||
aliases: std::iter::once(name)
|
aliases: std::iter::once(name)
|
||||||
.chain(aliases.iter().copied())
|
.chain(aliases.iter().copied())
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
//! What every metadata source has to be able to do.
|
//! What every metadata source has to be able to do.
|
||||||
|
|
||||||
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
use pstr_core::library::Title;
|
||||||
|
use pstr_core::metadata::{Enrichment, ProviderId, TitleMetadata};
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::matching::{Candidate, Query};
|
use crate::matching::{Candidate, Query};
|
||||||
|
|
||||||
/// A source of titles.
|
/// A source of titles.
|
||||||
///
|
///
|
||||||
/// Deliberately narrow: search, and the episode list of something search
|
/// Deliberately narrow: search, and enriching something search already
|
||||||
/// already matched. Both are requests to a third party, and neither happens
|
/// matched. Both are requests to a third party, and neither happens until the
|
||||||
/// until the viewer has turned enrichment on — but note what the second one
|
/// viewer has turned enrichment on — but note what the second one does *not*
|
||||||
/// does *not* need: it takes the id search returned, so it sends the provider
|
/// need: it takes the id search returned, so it sends nothing about the library
|
||||||
/// nothing about the library it did not already learn from the match.
|
/// it did not already learn from the match.
|
||||||
///
|
///
|
||||||
/// Not `#[async_trait]`: this is only ever used through a concrete type or an
|
/// Not `#[async_trait]`: this is only ever used through a concrete type or an
|
||||||
/// enum, never as `dyn Provider`, so the native `async fn` costs nothing here.
|
/// enum, never as `dyn Provider`, so the native `async fn` costs nothing here.
|
||||||
pub trait Provider: Send + Sync {
|
pub trait Provider: Send + Sync {
|
||||||
fn id(&self) -> ProviderId;
|
fn id(&self) -> ProviderId;
|
||||||
|
|
||||||
/// Whether a sequel is a *separate entry* rather than a second season of
|
|
||||||
/// the same one.
|
|
||||||
///
|
|
||||||
/// AniList works that way — `Oshi no Ko`, `Oshi no Ko 2nd Season` and
|
|
||||||
/// `Oshi no Ko 3rd Season` are three ids, each numbering its episodes from
|
|
||||||
/// one — so a library that files all three under one title has to search
|
|
||||||
/// again per season or seasons two upwards get no episode names at all (and,
|
|
||||||
/// worse, would be answered with season one's). TMDB files them as seasons
|
|
||||||
/// of one show and its episode list already carries season numbers, so
|
|
||||||
/// searching per season there would only find the wrong entry.
|
|
||||||
fn seasons_are_separate_entries(&self) -> bool;
|
|
||||||
|
|
||||||
/// Everything the provider thinks `query` might be, in its own order.
|
/// Everything the provider thinks `query` might be, in its own order.
|
||||||
///
|
///
|
||||||
/// Scoring is not the provider's job — it returns candidates and
|
/// Scoring is not the provider's job — it returns candidates and
|
||||||
@@ -40,13 +29,16 @@ pub trait Provider: Send + Sync {
|
|||||||
query: &Query,
|
query: &Query,
|
||||||
) -> impl std::future::Future<Output = Result<Vec<Candidate>>> + Send;
|
) -> impl std::future::Future<Output = Result<Vec<Candidate>>> + Send;
|
||||||
|
|
||||||
/// Every episode the provider lists for a title it already matched.
|
/// The episodes, and any art the search answer lacked, of `found` — the
|
||||||
|
/// entry `title` matched.
|
||||||
///
|
///
|
||||||
/// An empty list is a real answer — a film has no episodes, and plenty of
|
/// `title` is there for its files: a provider that files each sequel as a
|
||||||
/// series entries have none listed — and the caller caches it rather than
|
/// separate entry (AniList) has to know which seasons the library holds to
|
||||||
/// asking again on the next render.
|
/// know how far along the franchise to look. An empty episode list is a
|
||||||
fn episodes(
|
/// real answer — a film has none — and the caller records it.
|
||||||
|
fn enrich(
|
||||||
&self,
|
&self,
|
||||||
title: &TitleMetadata,
|
title: &Title,
|
||||||
) -> impl std::future::Future<Output = Result<Vec<EpisodeMetadata>>> + Send;
|
found: &TitleMetadata,
|
||||||
|
) -> impl std::future::Future<Output = Result<Enrichment>> + Send;
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,12 @@
|
|||||||
//! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the
|
//! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the
|
||||||
//! note in `pstr_core::metadata` on why that is off by default.
|
//! note in `pstr_core::metadata` on why that is off by default.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use pstr_core::library::{Title, TitleKind, title_key};
|
use pstr_core::library::{Title, TitleKind, title_key};
|
||||||
use pstr_core::metadata::{
|
use pstr_core::metadata::{
|
||||||
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::anilist::AniList;
|
use crate::anilist::AniList;
|
||||||
@@ -63,17 +64,24 @@ impl Source {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
|
/// The entry a stored match names, fetched again by its id. `None` from a
|
||||||
|
/// provider that has no details to add.
|
||||||
|
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
|
||||||
match self {
|
match self {
|
||||||
Self::AniList(provider) => provider.episodes(title).await,
|
Self::AniList(provider) => provider.entry(id).await,
|
||||||
Self::Tmdb(provider) => provider.episodes(title).await,
|
Self::Tmdb(_) => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seasons_are_separate_entries(&self) -> bool {
|
/// Whether [`Source::entry`] has anything to give.
|
||||||
|
fn has_details(&self) -> bool {
|
||||||
|
matches!(self, Self::AniList(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
match self {
|
match self {
|
||||||
Self::AniList(provider) => provider.seasons_are_separate_entries(),
|
Self::AniList(provider) => provider.enrich(title, found).await,
|
||||||
Self::Tmdb(provider) => provider.seasons_are_separate_entries(),
|
Self::Tmdb(provider) => provider.enrich(title, found).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +109,9 @@ impl MetadataService {
|
|||||||
.map_err(Error::Network)?;
|
.map_err(Error::Network)?;
|
||||||
|
|
||||||
let source = match config.provider {
|
let source = match config.provider {
|
||||||
ProviderId::AniList => Source::AniList(AniList::new(http.clone())),
|
ProviderId::AniList => {
|
||||||
|
Source::AniList(AniList::new(http.clone(), config.language.clone()))
|
||||||
|
}
|
||||||
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
|
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
|
||||||
http.clone(),
|
http.clone(),
|
||||||
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
|
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
|
||||||
@@ -204,136 +214,29 @@ impl MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the provider lists as the episodes of a title it matched.
|
/// The episodes of a matched title, and any art its match lacked.
|
||||||
///
|
///
|
||||||
/// Only ever called for a title that already has a match, because the
|
/// Only ever called for a title that already has a match, because the
|
||||||
/// provider's own id is what it takes — there is no second search here, and
|
/// provider's own id is what it takes — there is no second search here, and
|
||||||
/// nothing about the library goes out that the match did not already send.
|
/// nothing about the library goes out that the match did not already send.
|
||||||
/// An empty list is a real answer and belongs in the catalog: a film has no
|
///
|
||||||
/// episodes, and asking again per render is the trap `MetadataRecord`
|
/// An `Err` is a failure to ask and must not be stored: the title stays
|
||||||
/// exists to avoid.
|
/// un-enriched and the next match run asks again. An empty answer is an
|
||||||
pub async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
|
/// answer, and is — see `Catalog::set_enrichment`.
|
||||||
let episodes = self.source.episodes(title).await?;
|
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
|
let enrichment = self.source.enrich(title, found).await?;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"{}: {} episodes from {}",
|
"{}: {} episodes from {}{}",
|
||||||
title.name,
|
title.name,
|
||||||
episodes.len(),
|
enrichment.episodes.len(),
|
||||||
self.provider().label()
|
self.provider().label(),
|
||||||
);
|
if enrichment.backdrop_url.is_some() {
|
||||||
Ok(episodes)
|
", with a backdrop"
|
||||||
}
|
} else {
|
||||||
|
""
|
||||||
/// The episode list for a whole title, with a failure treated as an empty
|
|
||||||
/// one.
|
|
||||||
///
|
|
||||||
/// Deliberately not fatal to the title it belongs to: a poster and a
|
|
||||||
/// synopsis that arrived are worth keeping even when the episode request
|
|
||||||
/// was the one that hit the rate limit. The empty result is not cached as
|
|
||||||
/// an answer — the next match run asks again — because the only thing that
|
|
||||||
/// triggers one is a viewer pressing the button.
|
|
||||||
///
|
|
||||||
/// The match itself only ever answers for *one* entry, and on a provider
|
|
||||||
/// that files each sequel separately — AniList — that entry is season one.
|
|
||||||
/// So each further season of the title is searched for by name and its
|
|
||||||
/// episodes are tagged with the season they came from; without that,
|
|
||||||
/// seasons two and three have no episode names at all. See
|
|
||||||
/// [`MetadataService::season_episodes`].
|
|
||||||
pub async fn title_episodes(
|
|
||||||
&self,
|
|
||||||
title: &Title,
|
|
||||||
found: &TitleMetadata,
|
|
||||||
) -> Vec<EpisodeMetadata> {
|
|
||||||
let mut episodes = self
|
|
||||||
.episodes(found)
|
|
||||||
.await
|
|
||||||
.inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if !self.splits_seasons() {
|
|
||||||
return episodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Season one is what the title's own match already answered for.
|
|
||||||
let later: Vec<u32> = title
|
|
||||||
.seasons
|
|
||||||
.iter()
|
|
||||||
.filter_map(|season| season.number)
|
|
||||||
.filter(|number| *number > 1)
|
|
||||||
.collect();
|
|
||||||
for season in later {
|
|
||||||
match self.season_episodes(title, season).await {
|
|
||||||
Ok(found) => episodes.extend(found),
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!("episodes for {} season {season}: {error}", title.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
episodes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this provider files a sequel as its own entry, so that a title
|
|
||||||
/// with several seasons has to be searched for once per season.
|
|
||||||
pub fn splits_seasons(&self) -> bool {
|
|
||||||
self.source.seasons_are_separate_entries()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The episodes of one season of `title`, for a provider that files each
|
|
||||||
/// season separately.
|
|
||||||
///
|
|
||||||
/// A second search, not a second guess: the title's own match is season
|
|
||||||
/// one's entry, and asking it about episode one of season two would answer
|
|
||||||
/// with episode one of season one. So the season is searched for by the
|
|
||||||
/// name the provider itself uses — `Oshi no Ko 2nd Season`, and `Oshi no Ko
|
|
||||||
/// Season 2` when that finds nothing, which are the two conventions between
|
|
||||||
/// them covering everything AniList carries.
|
|
||||||
///
|
|
||||||
/// A miss is an empty list rather than an error: a season the provider has
|
|
||||||
/// never heard of is ordinary, and it leaves those rows named by their
|
|
||||||
/// filenames exactly as they were before.
|
|
||||||
pub async fn season_episodes(
|
|
||||||
&self,
|
|
||||||
title: &Title,
|
|
||||||
season: u32,
|
|
||||||
) -> Result<Vec<EpisodeMetadata>> {
|
|
||||||
if !self.splits_seasons() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut found = None;
|
|
||||||
for name in season_names(&title.name, season) {
|
|
||||||
// No year: a sequel airs years after the series the library named
|
|
||||||
// the folder for, so carrying the title's year over would penalise
|
|
||||||
// the very entry being looked for.
|
|
||||||
let query = Query::new(name, None, title.kind);
|
|
||||||
let candidates = self.source.search(&query).await?;
|
|
||||||
if let Some(matched) = matching::best(&query, candidates) {
|
|
||||||
found = Some(matched);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(found) = found else {
|
|
||||||
tracing::debug!("{}: no entry for season {season}", title.name);
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
|
|
||||||
let episodes = self.source.episodes(&found).await?;
|
|
||||||
tracing::debug!(
|
|
||||||
"{} season {season}: {} episodes from {:?}",
|
|
||||||
title.name,
|
|
||||||
episodes.len(),
|
|
||||||
found.name
|
|
||||||
);
|
);
|
||||||
// The entry numbers its own episodes from one; what makes them season
|
Ok(enrichment)
|
||||||
// two's is which entry they came from, and that has to be recorded here
|
|
||||||
// or nothing can look them up again.
|
|
||||||
Ok(episodes
|
|
||||||
.into_iter()
|
|
||||||
.map(|episode| EpisodeMetadata {
|
|
||||||
season: Some(season),
|
|
||||||
..episode
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download one piece of artwork.
|
/// Download one piece of artwork.
|
||||||
@@ -351,29 +254,179 @@ impl MetadataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What to search for when looking for one season of a series, best first.
|
/// What one match run has to do about one title.
|
||||||
///
|
#[derive(Debug, Clone)]
|
||||||
/// `2nd Season` is AniList's own convention and matches its romaji titles
|
pub enum Work {
|
||||||
/// outright; `Season 2` is what its English titles and most western libraries
|
/// Search for it, and enrich it if it matches.
|
||||||
/// use. Season one is never searched for this way — that is the title's own
|
Match(Title),
|
||||||
/// match.
|
/// It is already matched; only its enrichment is missing or old.
|
||||||
fn season_names(title: &str, season: u32) -> Vec<String> {
|
Enrich(Title, Box<TitleMetadata>),
|
||||||
vec![
|
/// It is matched, but stored before [`TitleMetadata::details`] existed:
|
||||||
format!("{title} {} Season", ordinal(season)),
|
/// fetch the same entry again by id — never searched for, so a match the
|
||||||
format!("{title} Season {season}"),
|
/// viewer picked by hand stays theirs — and enrich it too if `enrich`.
|
||||||
]
|
Refresh {
|
||||||
|
title: Title,
|
||||||
|
record: Box<MetadataRecord>,
|
||||||
|
enrich: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `2` → `2nd`. English ordinals, because that is what the provider writes.
|
impl Work {
|
||||||
fn ordinal(number: u32) -> String {
|
pub fn title(&self) -> &Title {
|
||||||
let suffix = match (number % 10, number % 100) {
|
match self {
|
||||||
(_, 11..=13) => "th",
|
Self::Match(title) | Self::Enrich(title, _) | Self::Refresh { title, .. } => title,
|
||||||
(1, _) => "st",
|
}
|
||||||
(2, _) => "nd",
|
}
|
||||||
(3, _) => "rd",
|
}
|
||||||
_ => "th",
|
|
||||||
};
|
/// What a piece of [`Work`] found, ready to store.
|
||||||
format!("{number}{suffix}")
|
#[derive(Debug)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub title_key: String,
|
||||||
|
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
|
||||||
|
/// misses included.
|
||||||
|
pub record: Option<MetadataRecord>,
|
||||||
|
/// What enriching the match found, when it was asked and answered. `None`
|
||||||
|
/// after a failure, which must leave the title un-enriched so the next run
|
||||||
|
/// asks again.
|
||||||
|
pub enrichment: Option<Enrichment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide what a match run over `titles` has to do.
|
||||||
|
///
|
||||||
|
/// Three kinds of work, and the difference is requests saved: a title that
|
||||||
|
/// already has a good match but has not been enriched — or was enriched longer
|
||||||
|
/// ago than a match is trusted — needs enriching only, and searching for it
|
||||||
|
/// again would ask the provider something it has already answered. One whose
|
||||||
|
/// match predates its details is fetched again by id, which is one request
|
||||||
|
/// and decides nothing.
|
||||||
|
///
|
||||||
|
/// A hand-picked entry is never searched for again, not even when `force`d —
|
||||||
|
/// that means "the automatic answers are wrong", and re-deciding the one title
|
||||||
|
/// the viewer already fixed by hand is the opposite of what they asked for. It
|
||||||
|
/// is still enriched and refreshed.
|
||||||
|
///
|
||||||
|
/// `details` is whether the provider has [`TitleMetadata::details`] to give
|
||||||
|
/// — see [`MetadataService::has_details`].
|
||||||
|
pub fn plan(
|
||||||
|
titles: Vec<Title>,
|
||||||
|
stored: &HashMap<String, MetadataRecord>,
|
||||||
|
enriched: &HashMap<String, (ProviderId, i64)>,
|
||||||
|
provider: ProviderId,
|
||||||
|
details: bool,
|
||||||
|
force: bool,
|
||||||
|
) -> Vec<Work> {
|
||||||
|
let now = now();
|
||||||
|
let mut work = Vec::new();
|
||||||
|
for title in titles {
|
||||||
|
let record = stored.get(&title.key);
|
||||||
|
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
|
||||||
|
if !pinned && (force || !is_usable(record, provider)) {
|
||||||
|
work.push(Work::Match(title));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(record) = record.filter(|record| record.metadata.is_some()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| {
|
||||||
|
*asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS
|
||||||
|
});
|
||||||
|
let stale_details = details
|
||||||
|
&& record
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|found| found.details.is_none());
|
||||||
|
if stale_details {
|
||||||
|
work.push(Work::Refresh {
|
||||||
|
title,
|
||||||
|
record: Box::new(record.clone()),
|
||||||
|
enrich: !fresh,
|
||||||
|
});
|
||||||
|
} else if !fresh && let Some(found) = record.metadata.clone() {
|
||||||
|
work.push(Work::Enrich(title, Box::new(found)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
work
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetadataService {
|
||||||
|
/// Whether this provider fills in [`TitleMetadata::details`], and so
|
||||||
|
/// whether a match stored without them is worth fetching again.
|
||||||
|
pub fn has_details(&self) -> bool {
|
||||||
|
self.source.has_details()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Do one piece of [`Work`].
|
||||||
|
///
|
||||||
|
/// A failed search is an `Err` and nothing is stored. A failed enrichment
|
||||||
|
/// after a successful search is not: the match is worth keeping, and the
|
||||||
|
/// title is simply left un-enriched — a poster and a synopsis that arrived
|
||||||
|
/// are worth showing even when the episode request was the one that hit the
|
||||||
|
/// rate limit.
|
||||||
|
pub async fn work(&self, work: &Work) -> Result<Outcome> {
|
||||||
|
match work {
|
||||||
|
Work::Match(title) => {
|
||||||
|
let record = self.record(title).await?;
|
||||||
|
let enrichment = match &record.metadata {
|
||||||
|
Some(found) => self.enrich_or_log(title, found).await,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Outcome {
|
||||||
|
title_key: record.title_key.clone(),
|
||||||
|
record: Some(record),
|
||||||
|
enrichment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Work::Enrich(title, found) => Ok(Outcome {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
record: None,
|
||||||
|
enrichment: Some(self.enrich(title, found).await?),
|
||||||
|
}),
|
||||||
|
Work::Refresh {
|
||||||
|
title,
|
||||||
|
record,
|
||||||
|
enrich,
|
||||||
|
} => {
|
||||||
|
let Some(stored) = &record.metadata else {
|
||||||
|
return Ok(Outcome {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
record: None,
|
||||||
|
enrichment: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// An id the provider no longer has keeps what was stored, with
|
||||||
|
// empty details so it is not asked about on every run.
|
||||||
|
let found = self
|
||||||
|
.source
|
||||||
|
.entry(&stored.remote_id)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| TitleMetadata {
|
||||||
|
details: Some(Default::default()),
|
||||||
|
..stored.clone()
|
||||||
|
});
|
||||||
|
let enrichment = match enrich {
|
||||||
|
true => self.enrich_or_log(title, &found).await,
|
||||||
|
false => None,
|
||||||
|
};
|
||||||
|
Ok(Outcome {
|
||||||
|
title_key: title.key.clone(),
|
||||||
|
record: Some(MetadataRecord {
|
||||||
|
metadata: Some(found),
|
||||||
|
fetched_at: now(),
|
||||||
|
..(**record).clone()
|
||||||
|
}),
|
||||||
|
enrichment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
|
||||||
|
self.enrich(title, found)
|
||||||
|
.await
|
||||||
|
.inspect_err(|error| tracing::warn!("enrich {}: {error}", title.name))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a stored answer can be used as-is.
|
/// Whether a stored answer can be used as-is.
|
||||||
@@ -412,22 +465,144 @@ mod tests {
|
|||||||
assert!(MetadataService::new(&tmdb, Some("key".into())).is_ok());
|
assert!(MetadataService::new(&tmdb, Some("key".into())).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The names a season is searched for by, in the order they are tried.
|
fn title(key: &str) -> Title {
|
||||||
|
Title {
|
||||||
|
key: key.into(),
|
||||||
|
name: key.into(),
|
||||||
|
year: None,
|
||||||
|
kind: TitleKind::Series,
|
||||||
|
seasons: Vec::new(),
|
||||||
|
share_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matched(key: &str, manual: bool) -> MetadataRecord {
|
||||||
|
MetadataRecord {
|
||||||
|
title_key: key.into(),
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
metadata: Some(TitleMetadata {
|
||||||
|
provider: ProviderId::AniList,
|
||||||
|
remote_id: "1".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: None,
|
||||||
|
}),
|
||||||
|
fetched_at: now(),
|
||||||
|
manual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
|
||||||
|
work.iter()
|
||||||
|
.map(|work| match work {
|
||||||
|
Work::Match(title) => (title.key.as_str(), "match"),
|
||||||
|
Work::Enrich(title, _) => (title.key.as_str(), "enrich"),
|
||||||
|
Work::Refresh { title, .. } => (title.key.as_str(), "refresh"),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A matched title is enriched once and then left alone until its
|
||||||
|
/// enrichment is as old as a match is trusted; an unasked title is matched.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_season_is_searched_for_the_way_the_provider_names_it() {
|
fn a_run_matches_the_unasked_and_enriches_the_unenriched() {
|
||||||
assert_eq!(
|
let stored = HashMap::from([
|
||||||
season_names("Oshi no Ko", 2),
|
("enriched".to_string(), matched("enriched", false)),
|
||||||
vec!["Oshi no Ko 2nd Season", "Oshi no Ko Season 2"]
|
("stale".to_string(), matched("stale", false)),
|
||||||
|
("bare".to_string(), matched("bare", false)),
|
||||||
|
]);
|
||||||
|
let enriched = HashMap::from([
|
||||||
|
("enriched".to_string(), (ProviderId::AniList, now())),
|
||||||
|
(
|
||||||
|
"stale".to_string(),
|
||||||
|
(ProviderId::AniList, now() - MATCH_TTL_SECS - 1),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
|
||||||
|
|
||||||
|
let work = plan(
|
||||||
|
titles,
|
||||||
|
&stored,
|
||||||
|
&enriched,
|
||||||
|
ProviderId::AniList,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(ordinal(1), "1st");
|
assert_eq!(
|
||||||
assert_eq!(ordinal(3), "3rd");
|
kinds(&work),
|
||||||
assert_eq!(ordinal(4), "4th");
|
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
|
||||||
// The teens are the ones a naive rule gets wrong, and a show does reach
|
);
|
||||||
// them: `11th` is not `11st`.
|
}
|
||||||
assert_eq!(ordinal(11), "11th");
|
|
||||||
assert_eq!(ordinal(12), "12th");
|
/// "Match again" re-searches everything except what the viewer pinned —
|
||||||
assert_eq!(ordinal(13), "13th");
|
/// and a pinned title that was never enriched is still enriched.
|
||||||
assert_eq!(ordinal(21), "21st");
|
#[test]
|
||||||
|
fn a_forced_run_leaves_a_pinned_match_alone_but_still_enriches_it() {
|
||||||
|
let stored = HashMap::from([
|
||||||
|
("pinned".to_string(), matched("pinned", true)),
|
||||||
|
("automatic".to_string(), matched("automatic", false)),
|
||||||
|
]);
|
||||||
|
let titles = ["pinned", "automatic"].map(title).to_vec();
|
||||||
|
|
||||||
|
let work = plan(
|
||||||
|
titles,
|
||||||
|
&stored,
|
||||||
|
&HashMap::new(),
|
||||||
|
ProviderId::AniList,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&work),
|
||||||
|
vec![("pinned", "enrich"), ("automatic", "match")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A match stored before details existed is fetched again by id — a
|
||||||
|
/// pinned one included, and without being searched for — and only by a
|
||||||
|
/// provider that has details to give.
|
||||||
|
#[test]
|
||||||
|
fn a_match_without_details_is_refreshed_rather_than_searched_for() {
|
||||||
|
let stored = HashMap::from([
|
||||||
|
("pinned".to_string(), matched("pinned", true)),
|
||||||
|
("automatic".to_string(), matched("automatic", false)),
|
||||||
|
]);
|
||||||
|
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]);
|
||||||
|
let titles = ["pinned", "automatic"].map(title).to_vec();
|
||||||
|
|
||||||
|
let work = plan(
|
||||||
|
titles.clone(),
|
||||||
|
&stored,
|
||||||
|
&enriched,
|
||||||
|
ProviderId::AniList,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&work),
|
||||||
|
vec![("pinned", "refresh"), ("automatic", "refresh")]
|
||||||
|
);
|
||||||
|
assert!(matches!(&work[0], Work::Refresh { enrich: false, .. }));
|
||||||
|
assert!(matches!(&work[1], Work::Refresh { enrich: true, .. }));
|
||||||
|
|
||||||
|
let work = plan(
|
||||||
|
titles,
|
||||||
|
&stored,
|
||||||
|
&enriched,
|
||||||
|
ProviderId::AniList,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A record from the other provider is never usable — switching provider has
|
/// A record from the other provider is never usable — switching provider has
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
|
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
|
||||||
//! anyway.
|
//! anyway.
|
||||||
|
|
||||||
use pstr_core::library::TitleKind;
|
use pstr_core::library::{Title, TitleKind};
|
||||||
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
|
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -73,12 +73,6 @@ impl Provider for Tmdb {
|
|||||||
ProviderId::Tmdb
|
ProviderId::Tmdb
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One show, every season under it — and [`Self::episodes`] already returns
|
|
||||||
/// them with their season numbers.
|
|
||||||
fn seasons_are_separate_entries(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
|
||||||
if self.api_key.trim().is_empty() {
|
if self.api_key.trim().is_empty() {
|
||||||
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
return Err(Error::MissingApiKey(ProviderId::Tmdb));
|
||||||
@@ -101,6 +95,19 @@ impl Provider for Tmdb {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The episode list, and nothing else: the match already carried a proper
|
||||||
|
/// backdrop, and TMDB files every season under one show, so the library's
|
||||||
|
/// seasons need no walking.
|
||||||
|
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||||
|
Ok(Enrichment {
|
||||||
|
episodes: self.episodes(found).await?,
|
||||||
|
backdrop_url: None,
|
||||||
|
chain: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tmdb {
|
||||||
/// Two requests: the show, to learn which seasons exist, and then those
|
/// Two requests: the show, to learn which seasons exist, and then those
|
||||||
/// seasons appended onto one more.
|
/// seasons appended onto one more.
|
||||||
///
|
///
|
||||||
@@ -153,6 +160,9 @@ impl Provider for Tmdb {
|
|||||||
EpisodeMetadata {
|
EpisodeMetadata {
|
||||||
season: Some(number),
|
season: Some(number),
|
||||||
number: episode.episode_number,
|
number: episode.episode_number,
|
||||||
|
absolute: None,
|
||||||
|
entry_season: None,
|
||||||
|
entry_number: None,
|
||||||
name: episode.name.clone().filter(|name| !name.is_empty()),
|
name: episode.name.clone().filter(|name| !name.is_empty()),
|
||||||
overview: episode.overview.clone().filter(|text| !text.is_empty()),
|
overview: episode.overview.clone().filter(|text| !text.is_empty()),
|
||||||
still_url: episode
|
still_url: episode
|
||||||
@@ -165,9 +175,7 @@ impl Provider for Tmdb {
|
|||||||
}
|
}
|
||||||
Ok(episodes)
|
Ok(episodes)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Tmdb {
|
|
||||||
/// One authenticated GET, paced, with TMDB's failures named.
|
/// One authenticated GET, paced, with TMDB's failures named.
|
||||||
///
|
///
|
||||||
/// TMDB is far more generous than AniList and a library-sized run rarely
|
/// TMDB is far more generous than AniList and a library-sized run rarely
|
||||||
@@ -343,6 +351,9 @@ impl Result_ {
|
|||||||
},
|
},
|
||||||
self.id
|
self.id
|
||||||
)),
|
)),
|
||||||
|
// Nothing here yet: TMDB's would come from a detail request
|
||||||
|
// per title, and the search answer has none of it.
|
||||||
|
details: None,
|
||||||
},
|
},
|
||||||
aliases,
|
aliases,
|
||||||
// Always: `search/multi` results are filtered on `media_type`
|
// Always: `search/multi` results are filtered on `media_type`
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Before submitting Android work, run the Rust workspace gate from the repository
|
|||||||
root, then:
|
root, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/build-android.sh check # ktlint, Android lint, host tests
|
bash scripts/build-android.sh check # ktlint, Android lint, host and screenshot tests
|
||||||
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
|
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,11 +87,13 @@ GitHub Actions expects the keystore itself as the base64-encoded
|
|||||||
Keep the keystore and passwords in a durable external secret manager; losing the
|
Keep the keystore and passwords in a durable external secret manager; losing the
|
||||||
key prevents users from upgrading an installed APK.
|
key prevents users from upgrading an installed APK.
|
||||||
|
|
||||||
Tagged workflows currently retain signed builds as workflow artifacts only.
|
A `v*` tag runs `release.yml`, which calls `android.yml` with
|
||||||
Publishing them to a GitHub release or Play remains disabled until the pinned
|
`signed_release: true` and publishes the signed APK as
|
||||||
libmpv build and resulting APK have passed the on-device playback matrix. The
|
`proton-stream-<version>-android.apk` next to the desktop artifacts, together
|
||||||
main release workflow publishes desktop artifacts only and fails if an APK or
|
with the `mpv-android-source-<rev>.tar.gz` corresponding source for the bundled
|
||||||
AAB enters its artifact set.
|
libmpv. The AAB is kept as the `android-bundle` workflow artifact only; nothing
|
||||||
|
uploads it to Play. A manual `android.yml` run with `signed_release` builds the
|
||||||
|
same signed files without publishing them.
|
||||||
|
|
||||||
## Licensing and Native Playback
|
## Licensing and Native Playback
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,51 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
|||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
### B61 — later seasons were captioned with season one's episode names
|
||||||
|
|
||||||
|
**Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and
|
||||||
|
Jigokuraku season 2 showed season one's episode names. Dan Da Dan, Kaiju No. 8
|
||||||
|
and The Apothecary Diaries had a handful of names numbered past the end of the
|
||||||
|
season. Bleach's had 20 rows called `Untitled`. Bleach `S17` (the Thousand-Year
|
||||||
|
Blood War) and Mushishi `S02`/`S03` had none at all. Jujutsu Kaisen 0 kept the
|
||||||
|
TV series' names after it was re-pinned to the film. Every desktop tile drew a
|
||||||
|
blurred strip of AniList banner.
|
||||||
|
|
||||||
|
**Cause.** Four, independent.
|
||||||
|
|
||||||
|
- `season_episodes` searched for `<title> 2nd Season`, and the scorer, seeing
|
||||||
|
the base entry's alias inside the query, picked the base entry again. That
|
||||||
|
search was [B11](#b11--seasons-past-the-first-had-no-episode-names-and-were-one-query-from-having-the-wrong-ones)'s
|
||||||
|
fix, and it only worked where the sequel's name scored higher than the base's.
|
||||||
|
- `streamingEpisodes` is what a streaming site published: partial, and
|
||||||
|
numbered however that site counted.
|
||||||
|
- A release's season is not an AniList entry. `S17` is four entries, and
|
||||||
|
Mushoku Tensei's `S01` is two.
|
||||||
|
- An empty episode list was never written, so a re-match kept the old one.
|
||||||
|
`bannerImage`, a 4.75:1 strip, was stored as the 16:9 backdrop.
|
||||||
|
|
||||||
|
**Fix.** AniList walks `SEQUEL` relations from the match. Each entry's episodes
|
||||||
|
come from ani.zip with TVDB's season, number and absolute number, plus the
|
||||||
|
entry's own numbering. `EpisodeGuide::get` meets whichever of those a filename
|
||||||
|
used without ever crossing seasons. Enrichment is stored as one unit,
|
||||||
|
replaced even when empty and recorded as asked. A changed match drops the old
|
||||||
|
entry's rows at once. The backdrop is ani.zip's 1920×1080 fanart, and the
|
||||||
|
banner is no longer stored. Schema v9 deletes the bad rows. See
|
||||||
|
[METADATA.md](METADATA.md).
|
||||||
|
|
||||||
|
**Verified.** `EpisodeGuide` tests in `pstr_core::metadata` (absolute numbers
|
||||||
|
in a season folder, by-entry seasons, no season crossing), and the catalog's
|
||||||
|
`an_empty_enrichment_clears_the_episodes_the_last_match_left`,
|
||||||
|
`pinning_a_different_entry_drops_the_old_entrys_episodes_at_once` and
|
||||||
|
`upgrading_drops_anilists_episode_rows_and_banners_but_keeps_its_matches`.
|
||||||
|
Live, against a copy of the same library through `pstr metadata match`:
|
||||||
|
|
||||||
|
- 0 lookups failed.
|
||||||
|
- Every numbered file is answered except 2 Attack on Titan files, 2 Bleach
|
||||||
|
files and one Mushoku Tensei special, which TVDB does not list.
|
||||||
|
- 29 of 44 matched titles have fanart, and none has a banner.
|
||||||
|
- A second run asks nothing.
|
||||||
|
|
||||||
### B60 — a failed "Add and crawl" left the crawl spinner running forever
|
### B60 — a failed "Add and crawl" left the crawl spinner running forever
|
||||||
|
|
||||||
**Symptom.** Pasting a link without its `#` fragment, or one already added,
|
**Symptom.** Pasting a link without its `#` fragment, or one already added,
|
||||||
|
|||||||
@@ -106,26 +106,22 @@ card (`App::tick_up_next`, `ui::player::up_next_card`) with *Play now* and
|
|||||||
chapters, autoplay on, a next episode, and playback actually running — a paused
|
chapters, autoplay on, a next episode, and playback actually running — a paused
|
||||||
episode is one somebody walked away from.
|
episode is one somebody walked away from.
|
||||||
|
|
||||||
Done: **per-episode metadata.** `Provider::episodes` fetches an episode list for
|
Done: **per-episode metadata.** `Provider::enrich` fetches the episode list —
|
||||||
a title that already matched (AniList `streamingEpisodes`, which is titles and
|
and, for AniList, the series' fanart — of a title that already matched, and
|
||||||
thumbnails only and no synopsis; TMDB `/tv/{id}` plus every season appended onto
|
`Catalog::set_enrichment` stores both and records that it asked (schema **v9**),
|
||||||
one more request), stored in `episode_metadata` — schema **v4**. The reconciling
|
so an empty answer is not re-asked every run. TMDB is `/tv/{id}` plus every
|
||||||
between a release's numbering and a provider's lives in one tested place,
|
season appended onto one more request. AniList walks the match's `SEQUEL`
|
||||||
`EpisodeGuide::get`: the season and number the filename states, then the
|
relations and takes each entry's episodes from ani.zip, which carries TVDB's
|
||||||
provider's absolute numbering, then season one for a file that states no season
|
season, number and absolute number — the numbering release groups use. The
|
||||||
at all. That last fallback is what makes `#057` line up with TMDB filing a
|
reconciling between a release's numbering and a provider's lives in one tested
|
||||||
64-episode anime under one season. Episode names show on the title page's rows,
|
place, `EpisodeGuide::get`. [METADATA.md](METADATA.md) has the audit that drove
|
||||||
in the player's title strip and in the "next:" line autoplay prints.
|
this, the order `get` tries things in, and what comes next.
|
||||||
|
|
||||||
**A season is a separate lookup on AniList.** `Provider::seasons_are_separate_entries`
|
The first version searched AniList for `<title> 2nd Season` per season and took
|
||||||
is what splits the two providers: TMDB files every season under one show and
|
episode names from `streamingEpisodes`; both were wrong often enough to show —
|
||||||
returns them numbered, while AniList makes each sequel its own entry numbering
|
the search found the base entry again and captioned Attack on Titan's later
|
||||||
from one (`[Oshi no Ko] 2nd Season`, id 166531, is not `[Oshi no Ko]`, id
|
seasons with season one's names, and `streamingEpisodes` is whatever a streaming
|
||||||
150672). So for AniList each season past the first is searched for by name —
|
site published. v9 deletes everything they stored.
|
||||||
`<title> 2nd Season`, then `<title> Season 2` — and its episodes are stored
|
|
||||||
tagged with that season. The absolute fallback in `EpisodeGuide::get` stops at
|
|
||||||
season one for the same reason: without that it answered `S02E01` with season
|
|
||||||
one's episode one, which is not a missing caption but a wrong one.
|
|
||||||
|
|
||||||
Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster
|
Done: **matching a title by hand.** `MATCH_FLOOR` is set where a wrong poster
|
||||||
costs more than none, and the price of that is a few titles it refuses to decide
|
costs more than none, and the price of that is a few titles it refuses to decide
|
||||||
@@ -145,11 +141,11 @@ rather than storing a miss, so the next run treats the title as one it has never
|
|||||||
asked about. Switching provider still clears everything, hand-picked rows
|
asked about. Switching provider still clears everything, hand-picked rows
|
||||||
included: a `remote_id` means nothing to the other provider.
|
included: a `remote_id` means nothing to the other provider.
|
||||||
|
|
||||||
Worth knowing before chasing a missing name: AniList's episode titles come from
|
Worth knowing before chasing a missing name: ani.zip only lists what TVDB has
|
||||||
`streamingEpisodes`, which is what streaming sites published, and for some shows
|
placed. A file it cannot answer is usually a special (`S02E00`), an episode TVDB
|
||||||
it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal
|
skipped, or one too new to be listed — Attack on Titan's last two files and two
|
||||||
Alchemist Brotherhood has 64. There is nothing to fix on our side for those;
|
of Bleach's forty-four are the standing examples. `pstr metadata match` prints
|
||||||
TMDB is the provider that has them.
|
how many episodes each title got.
|
||||||
|
|
||||||
Note the glyph trap: egui's bundled fonts cover the transport icons
|
Note the glyph trap: egui's bundled fonts cover the transport icons
|
||||||
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Metadata
|
||||||
|
|
||||||
|
How a library of filenames becomes posters, synopses and episode names, what
|
||||||
|
was wrong with the first version of it, and where it is going. The privacy
|
||||||
|
stance from `pstr_core::metadata` holds throughout: nothing here is requested
|
||||||
|
until the viewer turns enrichment on, and every new source named below sits
|
||||||
|
behind that same switch.
|
||||||
|
|
||||||
|
## What a real library showed
|
||||||
|
|
||||||
|
Audited against a 42-title, 758-file anime library matched on AniList
|
||||||
|
(schema v8, October 2026). The match itself — title to AniList entry — was
|
||||||
|
good. What hung off it was not:
|
||||||
|
|
||||||
|
1. **Later seasons captioned with season one's names.** Attack on Titan
|
||||||
|
seasons 2–4 and Jigokuraku season 2 all stored season one's episode list.
|
||||||
|
The per-season lookup searched for `Attack on Titan 2nd Season`, and the
|
||||||
|
similarity scorer, seeing the base entry's alias inside the query, picked the
|
||||||
|
base entry again. That is the failure `EpisodeGuide::get` already warned
|
||||||
|
about: not a missing caption but a wrong one.
|
||||||
|
2. **`streamingEpisodes` is not an episode list.** It is what a streaming site
|
||||||
|
published, partial and numbered however that site numbered it. Dan Da Dan
|
||||||
|
had 5 rows numbered 13–17, Kaiju No. 8 had 4–15, The Apothecary Diaries had
|
||||||
|
25–48 under two seasons at once, Bleach had 20 rows called `Untitled`, Akira
|
||||||
|
had a trailer. No row anywhere had a synopsis — AniList keeps none.
|
||||||
|
3. **A release's season is not an AniList entry.** Bleach `S17` is the
|
||||||
|
Thousand-Year Blood War, four separate AniList entries. Mushoku Tensei `S01`
|
||||||
|
is two (11 + 12 episodes). Attack on Titan's season folders hold
|
||||||
|
absolutely-numbered files — `S2` is episodes 26–37. Mushishi's `S03` is
|
||||||
|
*Zoku Shou 2*, which TVDB files as the second half of season two.
|
||||||
|
4. **Answers outlived their match.** `jujutsu kaisen 0 movie` kept the TV
|
||||||
|
series' 24 episode names after it was re-pinned to the film: an empty episode
|
||||||
|
list was never written, so the old one stayed.
|
||||||
|
5. **Desktop art was the wrong shape.** The 16:9 tile and the library's hero
|
||||||
|
prefer `backdrop_url`, which on AniList is `bannerImage` — a 1900×400 strip,
|
||||||
|
4.75:1, cached at 640×135. Cropped to 16:9 and stretched, that is a blurred
|
||||||
|
sliver from the middle of a banner. Android looks right because it draws the
|
||||||
|
2:3 cover.
|
||||||
|
6. **"Newest" sorts on nothing.** It reads the year from the filename, which 2
|
||||||
|
of 42 titles state.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Source | Key | What it is good for |
|
||||||
|
|---|---|---|
|
||||||
|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
|
||||||
|
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
|
||||||
|
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
|
||||||
|
| **TVmaze** | none | Live-action TV episodes, keyless. |
|
||||||
|
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
|
||||||
|
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
|
||||||
|
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
|
||||||
|
| AniDB, TVDB directly | registration / paid | Covered by ani.zip, which republishes their data. |
|
||||||
|
|
||||||
|
The fact the whole design turns on: **release groups number files the way TVDB
|
||||||
|
does.** `Bleach - S17E01` is TVDB season 17; Attack on Titan's 26–96 are TVDB
|
||||||
|
absolute numbers. ani.zip gives both for every AniList entry, so the join
|
||||||
|
between a filename and an episode is a lookup, not a guess.
|
||||||
|
|
||||||
|
## Phase 1 — correct data (done)
|
||||||
|
|
||||||
|
**Seasons by relation, not by name.** `AniList::enrich` starts at the matched
|
||||||
|
entry and follows its `SEQUEL` edges — TV formats first, a special or film only
|
||||||
|
when it is the sole link onward (Mushishi's chain runs through an OVA). Each
|
||||||
|
entry's episodes come from ani.zip. The walk stops as soon as every numbered file
|
||||||
|
in the title has an answer, at the end of the chain, or after
|
||||||
|
`MAX_CHAIN_ENTRIES`, so a one-season show costs one ani.zip request and no
|
||||||
|
relations query at all.
|
||||||
|
|
||||||
|
**One episode, three numberings.** `EpisodeMetadata` carries TVDB's
|
||||||
|
`(season, number)`, TVDB's `absolute` number, and its `(entry_season,
|
||||||
|
entry_number)` — the position of its entry among the franchise's TV entries,
|
||||||
|
and its number within that entry, which is how a release that follows AniList
|
||||||
|
rather than TVDB numbers its seasons. `EpisodeGuide::get` tries, in order:
|
||||||
|
|
||||||
|
1. the season and number the file states;
|
||||||
|
2. for a file in no season or season one, the provider's own absolute list,
|
||||||
|
then season one;
|
||||||
|
3. the absolute number — but only into the season the file states, so an
|
||||||
|
absolutely-numbered season folder (`Attack on Titan/S2/… - 26`) finds
|
||||||
|
S2E1, and `S03E13` can never be answered with season one's 13th;
|
||||||
|
4. the entry numbering, only for a season TVDB does not have at all — which is
|
||||||
|
what makes Mushishi `S03E01` *Zoku Shou 2* episode 1 while Mushoku Tensei
|
||||||
|
`S02E01`, a season TVDB does have, stays TVDB's.
|
||||||
|
|
||||||
|
`streamingEpisodes` is gone.
|
||||||
|
|
||||||
|
**Enrichment is recorded, empty or not.** A title's episode list and its
|
||||||
|
fanart are one *enrichment*, stored together by `Catalog::set_enrichment` in one
|
||||||
|
transaction that always replaces the old episode rows — so an empty answer
|
||||||
|
clears what a previous match left behind. `title_metadata.enriched_at` records
|
||||||
|
that it was asked, which is what keeps a film with no episodes from being
|
||||||
|
re-asked on every match run. A new match clears it; a failed enrichment leaves it
|
||||||
|
clear, so the title is asked again.
|
||||||
|
|
||||||
|
**No banner on a 16:9 tile.** AniList's `bannerImage` is no longer stored as a
|
||||||
|
backdrop. The backdrop is ani.zip's fanart where there is one; otherwise the tile
|
||||||
|
letterboxes the poster.
|
||||||
|
|
||||||
|
**Schema v9** adds the three numbering columns and `enriched_at`, deletes every
|
||||||
|
stored AniList episode row and every AniList banner-as-backdrop — all of it
|
||||||
|
`streamingEpisodes` data and wrong-shaped art — and leaves every title
|
||||||
|
un-enriched, so the next match run (which the app starts on its own) refills
|
||||||
|
them. Matches, hand-picked ones included, are kept.
|
||||||
|
|
||||||
|
## Phase 2 — art and order (done)
|
||||||
|
|
||||||
|
**More of the answer kept.** The AniList query now also asks for the full
|
||||||
|
start date, broadcast season, banner, popularity, status, the next airing
|
||||||
|
episode, spoiler-free tags ranked 60 or more, main studios, the `Director`
|
||||||
|
among the most relevant staff, and the relations that keep to one story
|
||||||
|
(`PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `SUMMARY`, `ALTERNATIVE`,
|
||||||
|
`COMPILATION`, `CONTAINS` — not `SPIN_OFF`, `CHARACTER` or `OTHER`, which reach
|
||||||
|
into other franchises). All of it lives in `TitleDetails`, stored as one JSON
|
||||||
|
column (**schema v10**). A match stored before that is fetched again *by id*
|
||||||
|
(`Work::Refresh`) — never searched for, so hand-picked matches stay — and a
|
||||||
|
refresh of the same entry keeps its episodes, fanart and enrichment record.
|
||||||
|
Enrichment also stores the chain of entries it walked.
|
||||||
|
|
||||||
|
**Art.** Three textures per title where there was one: the tile's (`Art::of`),
|
||||||
|
the 2:3 cover (`Art::poster`), and wide art (`Art::wide` — fanart, else the
|
||||||
|
AniList banner) decoded at up to 1920 px for the library's hero and the title
|
||||||
|
page's band. The banner, wrong in a 16:9 tile, fits those strips as drawn.
|
||||||
|
|
||||||
|
**Poster grid.** Settings → Appearance → *Library tiles*: covers (the default)
|
||||||
|
or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still
|
||||||
|
fitted whole.
|
||||||
|
|
||||||
|
**Names.** Settings → Appearance → *Title names*: English (the default, and
|
||||||
|
what Android always showed), romaji, or the share's own. Search matches all
|
||||||
|
of them whichever is shown.
|
||||||
|
|
||||||
|
**Sorts.** `pstr_core::browse`, shared by both clients. A–Z by the shown name, recently watched, recently added (from
|
||||||
|
`node_first_seen`, which survives recrawls; files present before v10 count as
|
||||||
|
added at 0), newest by air date, highest rated, most popular.
|
||||||
|
|
||||||
|
**Franchises.** `pstr_core::franchise::group` joins titles that share a
|
||||||
|
provider id — their own, a related one, or one their episode walk passed
|
||||||
|
through. The grid folds each franchise into its lead (the first series to air,
|
||||||
|
else the first film) with a `+N` badge; the stack button beside the sort turns
|
||||||
|
that off. The title page shows the whole franchise in release order. On the
|
||||||
|
library this was built against: Evangelion ×4, Made in Abyss ×4, Heaven's Feel
|
||||||
|
×3, Mushishi with its OVA, Jujutsu Kaisen with *0*, Chainsaw Man with *Reze*.
|
||||||
|
|
||||||
|
**Shelves.** `pstr_core::shelves::pick`: airing now, then directors, studios
|
||||||
|
and genres, each only when it spans three *franchises* — Made in Abyss's four
|
||||||
|
titles are one director's, and that is not a shelf — and a genre only under 40%
|
||||||
|
of the library. Four at most, over the unfiltered library. Here: *Directed by
|
||||||
|
Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*.
|
||||||
|
|
||||||
|
**The title page** adds the season, studios, director and next airing episode
|
||||||
|
to its line of facts, and the tags beside the genres.
|
||||||
|
|
||||||
|
**Android.** The same, through the bridge: `AndroidEngine::arrangement`
|
||||||
|
returns the grid's tiles and the shelves as keys into `library`'s titles, and
|
||||||
|
each `TitleRecord` carries its display name, franchise and details. The
|
||||||
|
library's bar has a sort menu with the franchise toggle in it; the grid shows
|
||||||
|
`+N` on a folded tile and up to three shelves above it; the title page has the
|
||||||
|
same facts, tags and franchise row as the desktop's; *Title names* is in
|
||||||
|
Settings → Appearance. Android's grid is always covers, so it has no
|
||||||
|
*Library tiles* choice.
|
||||||
|
|
||||||
|
Not done: TMDB fills no details.
|
||||||
|
|
||||||
|
## Phase 3 — reach
|
||||||
|
|
||||||
|
- **Local matching** against the offline database, so only ids leave the
|
||||||
|
machine.
|
||||||
|
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
|
||||||
|
whatever is not anime — Game of Thrones in a mostly-anime share.
|
||||||
|
- **Folders of films** matched per file: four Rebuild films in one folder are
|
||||||
|
four entries, not one.
|
||||||
|
- **Skip intro** from AniSkip for files without chapters, and filler flags from
|
||||||
|
Jikan.
|
||||||
@@ -178,7 +178,7 @@ that needs a viewer's eyes is a scripted case rather than a paragraph in a
|
|||||||
checklist.
|
checklist.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/build-android.sh check # ktlint, Android lint, host tests
|
bash scripts/build-android.sh check # ktlint, Android lint, host and screenshot tests
|
||||||
bash scripts/android-acceptance.sh --list # what the device suite covers
|
bash scripts/android-acceptance.sh --list # what the device suite covers
|
||||||
bash scripts/android-acceptance.sh # debug APK, one attached device
|
bash scripts/android-acceptance.sh # debug APK, one attached device
|
||||||
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
|
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
|
||||||
@@ -211,6 +211,19 @@ and checks the corner radius the desktop draws at. Both are lists of the *other*
|
|||||||
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
||||||
across fails here rather than being noticed in a screenshot.
|
across fails here rather than being noticed in a screenshot.
|
||||||
|
|
||||||
|
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
|
||||||
|
records — Library on a phone, a tablet, at font scale 1.5, while loading and
|
||||||
|
empty; a title on a phone and a tablet; History on a phone and a tablet; Shares;
|
||||||
|
the Settings index and its Playback and Storage pages; Downloads with a season
|
||||||
|
saved and empty; and the library and a title again in Catppuccin Latte — and
|
||||||
|
`check` compares them with the images in `app/src/test/screenshots`. A layout
|
||||||
|
regression compiles and passes everything else; here it fails, and the review
|
||||||
|
shows the changed image. After a deliberate change, `gradlew
|
||||||
|
recordRoborazziDebug` rewrites the images and they are committed with it.
|
||||||
|
Artwork loading is switched off (`LocalArtworkLoading`), so every tile shows
|
||||||
|
its generated placeholder whatever the timing, and the palette is the shipped
|
||||||
|
default except in the Latte captures.
|
||||||
|
|
||||||
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
||||||
equivalent:
|
equivalent:
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# builds from the registry alone.
|
# builds from the registry alone.
|
||||||
|
|
||||||
pkgname=proton-stream
|
pkgname=proton-stream
|
||||||
pkgver=0.5.0
|
pkgver=1.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="proton-stream">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="ps-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#8b6dff"/>
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
<stop offset="1" stop-color="#6d4aff"/>
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="ps-glyph" x1="0" y1="36" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<!-- The play mark, cut into the blocks a revision is fetched in: each one
|
||||||
|
decrypts on its own, which is the whole reason this app can seek. -->
|
||||||
|
<mask id="ps-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||||
|
<path d="M50 41.5v41a6 6 0 0 0 9.1 5.1l34.2-20.5a6 6 0 0 0 0-10.3L59.1 36.4A6 6 0 0 0 50 41.5z" fill="#FFFFFF"/>
|
||||||
|
<g fill="#000000">
|
||||||
|
<rect x="63" y="0" width="5.5" height="128"/>
|
||||||
|
<rect x="78.5" y="0" width="5.5" height="128"/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
|
|
||||||
<!-- The blocks a revision is fetched in: each one decrypts on its own, which
|
<!-- base: a rounded tile with a darker lower edge for depth -->
|
||||||
is the whole reason this app can seek. -->
|
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||||
<rect x="26" y="30" width="14" height="14" rx="3" fill="#ffffff" opacity="0.45"/>
|
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
|
||||||
<rect x="26" y="50" width="14" height="14" rx="3" fill="#ffffff" opacity="0.7"/>
|
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||||
<rect x="26" y="70" width="14" height="14" rx="3" fill="#ffffff" opacity="0.45"/>
|
|
||||||
<rect x="26" y="90" width="14" height="14" rx="3" fill="#ffffff" opacity="0.25"/>
|
<!-- glyph shadow, then the glyph itself -->
|
||||||
<!-- play -->
|
<g transform="translate(0 3)">
|
||||||
<path fill="#ffffff" d="M56 34l46 30-46 30z"/>
|
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 923 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -8,13 +8,17 @@ android_dir="$repo_root/android"
|
|||||||
usage() {
|
usage() {
|
||||||
echo "usage: $0 {debug|check|release|signed|all}"
|
echo "usage: $0 {debug|check|release|signed|all}"
|
||||||
echo
|
echo
|
||||||
echo " check ktlint, Android lint and host unit tests — no device needed."
|
echo " check ktlint, Android lint, host unit tests and screenshot comparison —"
|
||||||
|
echo " no device needed. Re-record changed screens with"
|
||||||
|
echo " 'gradlew recordRoborazziDebug' and commit the images."
|
||||||
echo " On-device coverage is scripts/android-acceptance.sh."
|
echo " On-device coverage is scripts/android-acceptance.sh."
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
debug) tasks=(assembleDebug) ;;
|
debug) tasks=(assembleDebug) ;;
|
||||||
check) tasks=(ktlintCheck lintDebug testDebugUnitTest) ;;
|
# verifyRoborazziDebug *is* testDebugUnitTest, with the screenshots compared
|
||||||
|
# against src/test/screenshots instead of skipped.
|
||||||
|
check) tasks=(ktlintCheck lintDebug verifyRoborazziDebug) ;;
|
||||||
release) tasks=(assembleRelease bundleRelease) ;;
|
release) tasks=(assembleRelease bundleRelease) ;;
|
||||||
signed)
|
signed)
|
||||||
required=(
|
required=(
|
||||||
@@ -31,7 +35,7 @@ case "${1:-}" in
|
|||||||
done
|
done
|
||||||
tasks=(assembleRelease bundleRelease)
|
tasks=(assembleRelease bundleRelease)
|
||||||
;;
|
;;
|
||||||
all) tasks=(ktlintCheck lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
|
all) tasks=(ktlintCheck lintDebug verifyRoborazziDebug assembleDebug assembleRelease bundleRelease) ;;
|
||||||
*) usage >&2; exit 2 ;;
|
*) usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ source_dir="${PSTR_MPV_SOURCE_DIR:-$repo_root/.build/mpv-android}"
|
|||||||
revision=20a3fa526fac6d3fe267aee0d4c349893fee65a3
|
revision=20a3fa526fac6d3fe267aee0d4c349893fee65a3
|
||||||
destination="$repo_root/android/app/build/generated/mpv"
|
destination="$repo_root/android/app/build/generated/mpv"
|
||||||
|
|
||||||
|
# The staged tree depends only on the pinned revision and on this script, which
|
||||||
|
# also pins the NDK. A tree stamped with both is reused as is: that is what lets
|
||||||
|
# CI restore it from a cache instead of rebuilding FFmpeg and every other
|
||||||
|
# dependency from source on each run.
|
||||||
|
stamp="$revision $(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)"
|
||||||
|
if [[ "$(cat "$destination/STAMP" 2>/dev/null)" == "$stamp" &&
|
||||||
|
-f "$destination/jniLibs/arm64-v8a/libmpv.so" &&
|
||||||
|
-f "$destination/jniLibs/x86_64/libmpv.so" &&
|
||||||
|
-f "$destination/mpv-android-source-$revision.tar.gz" ]]; then
|
||||||
|
echo "libmpv $revision is already staged in $destination"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -f -- "$destination/STAMP"
|
||||||
|
|
||||||
if [[ ! -d "$source_dir/.git" ]]; then
|
if [[ ! -d "$source_dir/.git" ]]; then
|
||||||
git clone https://github.com/mpv-android/mpv-android.git "$source_dir"
|
git clone https://github.com/mpv-android/mpv-android.git "$source_dir"
|
||||||
fi
|
fi
|
||||||
@@ -191,3 +205,5 @@ tar \
|
|||||||
-czf "$destination/mpv-android-source-$revision.tar.gz" \
|
-czf "$destination/mpv-android-source-$revision.tar.gz" \
|
||||||
.
|
.
|
||||||
printf '%s\n' "$revision" > "$destination/REVISION"
|
printf '%s\n' "$revision" > "$destination/REVISION"
|
||||||
|
# Last, so an interrupted run never leaves a stamp over a partial tree.
|
||||||
|
printf '%s\n' "$stamp" > "$destination/STAMP"
|
||||||