mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e6778e1ea | ||
|
|
dc85890a9f | ||
|
|
4ef62255c0 | ||
|
|
1a9a4624f8 | ||
|
|
cb03f96eca | ||
|
|
46bb68e3a5 | ||
|
|
f1b3ad9637 | ||
|
|
352fdf1933 | ||
|
|
887df9e998 | ||
|
|
bca999a904 | ||
|
|
359d397a69 | ||
|
|
b399f84f54 | ||
|
|
f4961fd6a8 | ||
|
|
6db6909824 | ||
|
|
8b95cf7a05 | ||
|
|
c2e9be45de | ||
|
|
0a9bc30b72 |
No files matched your search
@@ -0,0 +1,197 @@
|
||||
name: Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
paths:
|
||||
- 'android/**'
|
||||
- 'crates/pstr-android/**'
|
||||
- 'crates/pstr-core/**'
|
||||
- 'crates/pstr-meta/**'
|
||||
- 'crates/pstr-stream/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'scripts/build-android.sh'
|
||||
- 'scripts/build-libmpv-android.sh'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
signed_release:
|
||||
description: Build artifacts with the configured release key
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
ANDROID_API: '36'
|
||||
ANDROID_BUILD_TOOLS: '37.0.0'
|
||||
ANDROID_NDK: '29.0.14206865'
|
||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Validate, debug, and unsigned release
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Enforce release versions and tag ancestry
|
||||
shell: bash
|
||||
run: |
|
||||
workspace_version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} found && /^version = /{gsub(/[" ]/, "", $3); print $3; exit}' Cargo.toml)"
|
||||
test -n "$workspace_version"
|
||||
if [[ ! "$workspace_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::workspace version is not supported Android semver: $workspace_version"
|
||||
exit 1
|
||||
fi
|
||||
major="${BASH_REMATCH[1]}"
|
||||
minor="${BASH_REMATCH[2]}"
|
||||
patch="${BASH_REMATCH[3]}"
|
||||
test "$minor" -le 999
|
||||
test "$patch" -le 999
|
||||
expected_code=$((major * 1000000 + minor * 1000 + patch))
|
||||
test "$expected_code" -ge 1
|
||||
test "$expected_code" -le 2100000000
|
||||
echo "EXPECTED_ANDROID_VERSION_NAME=$workspace_version" >> "$GITHUB_ENV"
|
||||
echo "EXPECTED_ANDROID_VERSION_CODE=$expected_code" >> "$GITHUB_ENV"
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
tag="${GITHUB_REF_NAME#v}"
|
||||
test "$tag" = "$workspace_version"
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
|
||||
fi
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install native build prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends \
|
||||
autoconf automake build-essential cmake curl git gperf libtool \
|
||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||
|
||||
- name: Install Android SDK and NDK
|
||||
run: >-
|
||||
sdkmanager
|
||||
"platforms;android-${ANDROID_API}"
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
"ndk;${ANDROID_NDK}"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- name: Validate Android version derivation and override
|
||||
shell: bash
|
||||
run: |
|
||||
resolved="$(gradle --no-daemon -q -p android :app:printAndroidVersion)"
|
||||
grep -Fx "versionName=$EXPECTED_ANDROID_VERSION_NAME" <<< "$resolved"
|
||||
grep -Fx "versionCode=$EXPECTED_ANDROID_VERSION_CODE" <<< "$resolved"
|
||||
if [[ "$EXPECTED_ANDROID_VERSION_CODE" -lt 2100000000 ]]; then
|
||||
override_code=$((EXPECTED_ANDROID_VERSION_CODE + 1))
|
||||
else
|
||||
override_code=$((EXPECTED_ANDROID_VERSION_CODE - 1))
|
||||
fi
|
||||
overridden="$(ANDROID_VERSION_CODE="$override_code" gradle --no-daemon -q -p android :app:printAndroidVersion)"
|
||||
grep -Fx "versionName=$EXPECTED_ANDROID_VERSION_NAME" <<< "$overridden"
|
||||
grep -Fx "versionCode=$override_code" <<< "$overridden"
|
||||
|
||||
- name: Install native build helpers
|
||||
run: |
|
||||
cargo install cargo-ndk --version 4.1.2 --locked
|
||||
cargo install uniffi --version 0.32.0 --features cli --locked
|
||||
|
||||
- name: Android checks and artifacts
|
||||
run: bash scripts/build-android.sh all
|
||||
|
||||
signed-release:
|
||||
name: Signed release
|
||||
if: false
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
ANDROID_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
|
||||
ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||
ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install native build prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends \
|
||||
autoconf automake build-essential cmake curl git gperf libtool \
|
||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||
|
||||
- name: Install Android SDK and NDK
|
||||
run: >-
|
||||
sdkmanager
|
||||
"platforms;android-${ANDROID_API}"
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
"ndk;${ANDROID_NDK}"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-linux-android,x86_64-linux-android
|
||||
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- name: Install native build helpers
|
||||
run: |
|
||||
cargo install cargo-ndk --version 4.1.2 --locked
|
||||
cargo install uniffi --version 0.32.0 --features cli --locked
|
||||
|
||||
- name: Restore release keystore
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
test -n "$KEYSTORE_BASE64"
|
||||
test -n "$ANDROID_RELEASE_STORE_PASSWORD"
|
||||
test -n "$ANDROID_RELEASE_KEY_ALIAS"
|
||||
test -n "$ANDROID_RELEASE_KEY_PASSWORD"
|
||||
ANDROID_RELEASE_STORE_FILE="$RUNNER_TEMP/proton-stream-release.jks"
|
||||
echo "ANDROID_RELEASE_STORE_FILE=$ANDROID_RELEASE_STORE_FILE" >> "$GITHUB_ENV"
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$ANDROID_RELEASE_STORE_FILE"
|
||||
|
||||
- name: Build signed APK and AAB
|
||||
run: bash scripts/build-android.sh signed
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-signed
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/bundle/release/*.aab
|
||||
if-no-files-found: error
|
||||
@@ -20,6 +20,9 @@ permissions:
|
||||
|
||||
env:
|
||||
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:
|
||||
verify:
|
||||
@@ -57,6 +60,11 @@ jobs:
|
||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||
{ 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:
|
||||
name: Linux artifacts
|
||||
needs: verify
|
||||
@@ -141,7 +149,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish
|
||||
name: Publish desktop artifacts
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [linux, windows]
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -153,6 +161,13 @@ jobs:
|
||||
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
|
||||
run: |
|
||||
cd artifacts
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/.build/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
@@ -7,6 +8,14 @@
|
||||
# Build artifacts.
|
||||
/dist
|
||||
|
||||
# Android and Gradle generated state.
|
||||
/android/.gradle/
|
||||
/android/.kotlin/
|
||||
/android/build/
|
||||
/android/*/build/
|
||||
/android/local.properties
|
||||
/android/app/.cxx/
|
||||
|
||||
# makepkg leftovers.
|
||||
/packaging/pkg/
|
||||
/packaging/src/
|
||||
|
||||
Generated
+412
-8
@@ -150,6 +150,23 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
|
||||
|
||||
[[package]]
|
||||
name = "android_log-sys"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||
|
||||
[[package]]
|
||||
name = "android_logger"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"env_filter",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -260,6 +277,59 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
|
||||
dependencies = [
|
||||
"askama_macros",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
|
||||
dependencies = [
|
||||
"askama_parser",
|
||||
"basic-toml",
|
||||
"glob",
|
||||
"memchr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc-hash 2.1.3",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_macros"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
|
||||
dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"unicode-ident",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -448,6 +518,15 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "0.19.3"
|
||||
@@ -731,6 +810,39 @@ dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo-platform"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cargo_metadata"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"cargo-platform",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast5"
|
||||
version = "0.11.1"
|
||||
@@ -1720,6 +1832,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.35.0"
|
||||
@@ -1938,6 +2060,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -2157,6 +2288,12 @@ dependencies = [
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
@@ -2235,6 +2372,17 @@ dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "goblin"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
|
||||
dependencies = [
|
||||
"log",
|
||||
"plain",
|
||||
"scroll",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -2669,6 +2817,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3072,6 +3222,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3177,6 +3333,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -3814,7 +3980,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"nom",
|
||||
"nom 8.0.0",
|
||||
"num-bigint-dig",
|
||||
"num-traits",
|
||||
"num_enum",
|
||||
@@ -4094,9 +4260,28 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"parking_lot",
|
||||
"pstr-core",
|
||||
"pstr-meta",
|
||||
"pstr-stream",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"eframe",
|
||||
@@ -4108,6 +4293,8 @@ dependencies = [
|
||||
"pstr-meta",
|
||||
"pstr-player",
|
||||
"pstr-stream",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4116,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -4134,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -4152,9 +4339,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -4166,12 +4352,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
"libmpv2",
|
||||
"libmpv2-sys",
|
||||
"pstr-core",
|
||||
"pstr-stream",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
@@ -4180,7 +4367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
@@ -4409,6 +4596,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.16"
|
||||
@@ -4733,6 +4932,26 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scroll"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
|
||||
dependencies = [
|
||||
"scroll_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scroll_derive"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
@@ -4814,6 +5033,10 @@ name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
@@ -4869,6 +5092,15 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -5023,6 +5255,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.42.1"
|
||||
@@ -5054,6 +5292,12 @@ version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.19.2"
|
||||
@@ -5258,6 +5502,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
dependencies = [
|
||||
"smawk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5395,6 +5648,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -5425,6 +5693,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -5614,6 +5888,127 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "uniffi"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"camino",
|
||||
"cargo_metadata",
|
||||
"clap",
|
||||
"uniffi_bindgen",
|
||||
"uniffi_core",
|
||||
"uniffi_macros",
|
||||
"uniffi_pipeline",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_bindgen"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
"camino",
|
||||
"cargo_metadata",
|
||||
"fs-err",
|
||||
"glob",
|
||||
"goblin",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"textwrap",
|
||||
"toml",
|
||||
"uniffi_internal_macros",
|
||||
"uniffi_meta",
|
||||
"uniffi_pipeline",
|
||||
"uniffi_udl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_core"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"once_cell",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_internal_macros"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_macros"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"fs-err",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.119",
|
||||
"toml",
|
||||
"uniffi_meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_meta"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"siphasher",
|
||||
"uniffi_internal_macros",
|
||||
"uniffi_pipeline",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_pipeline"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"tempfile",
|
||||
"uniffi_internal_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uniffi_udl"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"textwrap",
|
||||
"uniffi_meta",
|
||||
"weedle2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -6007,6 +6402,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weedle2"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
||||
+3
-1
@@ -7,10 +7,11 @@ members = [
|
||||
"crates/pstr-player",
|
||||
"crates/pstr-app",
|
||||
"crates/pstr-cli",
|
||||
"crates/pstr-android",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -90,6 +91,7 @@ lru = "0.12"
|
||||
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
||||
# be tested against a fake without a network or an account.
|
||||
async-trait = "0.1"
|
||||
uniffi = "0.32"
|
||||
|
||||
# The SDK builds against the registry — 0.4.0 is published. To test an
|
||||
# unpublished `../proton-sdk-rs` change against this app, add locally and do
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# proton-stream
|
||||
|
||||
A Netflix-style desktop client for **Proton Drive public links**.
|
||||
A Netflix-style cross-platform client for **Proton Drive public links**.
|
||||
|
||||
Paste a share URL and its password; get a browsable, streamable library. No
|
||||
Proton account, no server to host, no download step — episodes start playing in
|
||||
@@ -28,6 +28,11 @@ countdown, optional metadata, themes and share management. What is left is mostl
|
||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
||||
|
||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
||||
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
||||
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||
|
||||
```bash
|
||||
cargo run --release -p pstr-app # the app
|
||||
```
|
||||
@@ -64,7 +69,7 @@ against the SDK's own range download.
|
||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||
| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares 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. |
|
||||
| Platforms | Linux and Windows. The macOS packaging is written but has not been run on a Mac. |
|
||||
| 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
|
||||
|
||||
@@ -84,6 +89,10 @@ and a portable `.zip` plus an `.msi` on Windows — are produced by
|
||||
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
||||
per-platform detail.
|
||||
|
||||
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
||||
|
||||
## Privacy
|
||||
|
||||
- The share URL fragment **is** the decryption password. It and any custom
|
||||
@@ -99,4 +108,6 @@ per-platform detail.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||
application and its combined native distribution are GPL-3.0-or-later; see
|
||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||
@@ -0,0 +1,29 @@
|
||||
# ktlint configuration.
|
||||
#
|
||||
# The whole standard ruleset is off, and rules are enabled one at a time. That
|
||||
# is deliberate: turning it all on reports ~540 findings on the existing tree,
|
||||
# almost all of them line-wrapping preference, and a mechanical reformat of
|
||||
# every file would bury the changes that matter in review. The rules kept here
|
||||
# are the ones whose violation is a defect or a real maintenance cost rather
|
||||
# than a matter of taste — starting with the unused imports that were the
|
||||
# evidence no gate ran at all.
|
||||
#
|
||||
# Adding a rule is cheap. Do it with the reformat it implies in its own commit.
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_standard = disabled
|
||||
ktlint_standard_no-unused-imports = enabled
|
||||
ktlint_standard_no-wildcard-imports = enabled
|
||||
ktlint_standard_no-semi = enabled
|
||||
ktlint_standard_no-trailing-spaces = enabled
|
||||
ktlint_standard_no-consecutive-blank-lines = enabled
|
||||
ktlint_standard_no-empty-file = enabled
|
||||
ktlint_standard_final-newline = enabled
|
||||
ktlint_standard_filename = enabled
|
||||
ktlint_standard_no-line-break-before-assignment = enabled
|
||||
ktlint_standard_no-multi-spaces = enabled
|
||||
|
||||
# Vendored from rustls-platform-verifier. Restyling it would turn the next sync
|
||||
# with upstream into a merge conflict for no benefit.
|
||||
[**/org/rustls/**.kt]
|
||||
ktlint = disabled
|
||||
@@ -0,0 +1,20 @@
|
||||
# proton-stream for Android license
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
Copyright (c) proton-stream contributors.
|
||||
|
||||
The Android application under this directory is free software: you may
|
||||
redistribute it and/or modify it under the terms of the GNU General Public
|
||||
License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the [GNU GPL version 3](https://www.gnu.org/licenses/gpl-3.0.html)
|
||||
for the complete license text.
|
||||
|
||||
This licensing choice applies to the Android application and its distribution,
|
||||
including the combined native player. Rust crates outside `android/` retain the
|
||||
licenses declared in their manifests; the repository root `LICENSE` remains the
|
||||
MIT license for those components.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Third-Party Notices
|
||||
|
||||
The Android distribution combines components under compatible open-source
|
||||
licenses. Preserve their notices and license texts in source and binary
|
||||
distributions.
|
||||
|
||||
| Component | License |
|
||||
|---|---|
|
||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||
| Kotlin | Apache-2.0 |
|
||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||
| UniFFI | MPL-2.0 |
|
||||
| proton-stream shared Rust crates | MIT |
|
||||
|
||||
This summary is not a replacement for dependency license files. Before a
|
||||
release, generate an inventory from the resolved Cargo and Gradle lock data,
|
||||
review it, and package the complete license texts in the application. A bundled
|
||||
libmpv release must also identify the exact upstream source revision, local
|
||||
patches, configuration flags, and reproducible build instructions, and make the
|
||||
corresponding source available as the GPL requires.
|
||||
@@ -0,0 +1,299 @@
|
||||
import org.gradle.api.tasks.Exec
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ktlint)
|
||||
}
|
||||
|
||||
val repositoryRoot = rootProject.projectDir.parentFile
|
||||
val workspaceVersion = Regex("""(?m)^version\s*=\s*\"([^\"]+)\"""")
|
||||
.find(repositoryRoot.resolve("Cargo.toml").readText())
|
||||
?.groupValues?.get(1)
|
||||
?: error("workspace version is missing from Cargo.toml")
|
||||
|
||||
fun semverVersionCode(version: String): Int {
|
||||
val match = Regex("""^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$""").matchEntire(version)
|
||||
?: error("Android version must start with major.minor.patch: $version")
|
||||
val (major, minor, patch) = match.destructured.toList().take(3).map(String::toLong)
|
||||
require(minor in 0..999 && patch in 0..999) { "minor and patch must fit three digits" }
|
||||
val code = major * 1_000_000 + minor * 1_000 + patch
|
||||
require(code in 1..2_100_000_000) { "semantic version is outside Android's version-code range" }
|
||||
return code.toInt()
|
||||
}
|
||||
|
||||
// The visible Android version is the workspace/release-tag version. Only the
|
||||
// monotonically increasing Play version code may be overridden by release CI.
|
||||
val releaseVersionName = workspaceVersion
|
||||
val versionCodeOverride = providers.environmentVariable("ANDROID_VERSION_CODE").orNull
|
||||
val releaseVersionCode = versionCodeOverride?.toIntOrNull()
|
||||
?: if (versionCodeOverride == null) semverVersionCode(releaseVersionName)
|
||||
else error("ANDROID_VERSION_CODE must be an integer")
|
||||
require(releaseVersionCode in 1..2_100_000_000) { "ANDROID_VERSION_CODE must be a positive Play-compatible integer" }
|
||||
|
||||
tasks.register("printAndroidVersion") {
|
||||
group = "help"
|
||||
description = "Prints the resolved Android version name and code for release validation."
|
||||
doLast {
|
||||
println("versionName=$releaseVersionName")
|
||||
println("versionCode=$releaseVersionCode")
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.narl.protonstream"
|
||||
compileSdk = 36
|
||||
buildToolsVersion = "37.0.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.narl.protonstream"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = releaseVersionCode
|
||||
versionName = releaseVersionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DPSTR_MPV_ROOT=${layout.buildDirectory.dir("generated/mpv").get().asFile.absolutePath}"
|
||||
arguments += "-DPSTR_RUST_JNI_ROOT=${layout.buildDirectory.dir("generated/jniLibs").get().asFile.absolutePath}"
|
||||
cppFlags += listOf("-std=c++20")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets.named("main") {
|
||||
java.srcDir(layout.buildDirectory.dir("generated/source/uniffi"))
|
||||
jniLibs.srcDir(layout.buildDirectory.dir("generated/jniLibs"))
|
||||
jniLibs.srcDir(layout.buildDirectory.dir("generated/mpv/jniLibs"))
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
|
||||
val releaseStore = System.getenv("ANDROID_RELEASE_STORE_FILE")
|
||||
if (releaseStore != null) {
|
||||
signingConfigs.create("release") {
|
||||
storeFile = file(releaseStore)
|
||||
storePassword = System.getenv("ANDROID_RELEASE_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_RELEASE_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
// Applied to the shipping APK on purpose; the file explains the
|
||||
// trade-off and what each keep is repairing.
|
||||
"proguard-instrumentation.pro",
|
||||
)
|
||||
testProguardFiles("proguard-test-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
// The minified path is what ships, and R8 breaks reflective UniFFI/JNA
|
||||
// surfaces in ways no debug run can see. CI sets this to `release` and runs
|
||||
// connectedAndroidTest against it; locally the default keeps builds fast.
|
||||
testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug"
|
||||
|
||||
lint {
|
||||
warningsAsErrors = true
|
||||
abortOnError = true
|
||||
checkDependencies = true
|
||||
// The baseline records what was already here when the gate went in, so
|
||||
// anything *new* fails. It is not a permanent exemption: every entry in
|
||||
// it maps to an open bug in docs/BUGS.md (the SharedPreferences
|
||||
// `commit()` calls are B20, the PiP one is B40), and the file should get
|
||||
// shorter as those land. Regenerate with `gradlew updateLintBaseline`
|
||||
// after a fix, and never to make an unrelated failure go away.
|
||||
baseline = file("lint-baseline.xml")
|
||||
// Generated UniFFI bindings are not ours to fix.
|
||||
disable += setOf("UnusedIds")
|
||||
// Dependency freshness is not a defect, and as errors these break the
|
||||
// gate whenever someone else cuts a release. Upgrades are a deliberate
|
||||
// act with their own verification; `gradlew lintDebug -Dlint...` or a
|
||||
// dependency bot is the right place to surface them, not CI.
|
||||
informational += setOf(
|
||||
"GradleDependency",
|
||||
"NewerVersionAvailable",
|
||||
"AndroidGradlePluginVersion",
|
||||
)
|
||||
sarifReport = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs.useLegacyPackaging = false
|
||||
resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
val rustJniDir = layout.buildDirectory.dir("generated/jniLibs")
|
||||
val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
||||
val hostRustLibrary = repositoryRoot.resolve(
|
||||
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||
)
|
||||
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
||||
|
||||
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||
group = "native"
|
||||
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||
workingDir(repositoryRoot)
|
||||
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
||||
outputs.files(
|
||||
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
||||
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
||||
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
||||
stagedMpvDir.map { it.file("REVISION") },
|
||||
)
|
||||
}
|
||||
|
||||
val buildRustHost by tasks.registering(Exec::class) {
|
||||
group = "rust"
|
||||
description = "Builds the host cdylib used by UniFFI binding generation."
|
||||
workingDir(repositoryRoot)
|
||||
commandLine("cargo", "build", "-p", "pstr-android", "--lib")
|
||||
inputs.files(fileTree(repositoryRoot.resolve("crates")) { include("**/*.rs", "**/Cargo.toml") })
|
||||
outputs.file(hostRustLibrary)
|
||||
}
|
||||
|
||||
val generateUniFfiBindings by tasks.registering(Exec::class) {
|
||||
group = "rust"
|
||||
description = "Generates Kotlin bindings from the pstr-android cdylib."
|
||||
dependsOn(buildRustHost)
|
||||
workingDir(repositoryRoot)
|
||||
doFirst { uniffiSourceDir.get().asFile.mkdirs() }
|
||||
commandLine(
|
||||
"cargo", "run", "-p", "pstr-android", "--features", "bindgen", "--bin", "uniffi-bindgen", "--", "generate",
|
||||
"--library", hostRustLibrary.absolutePath,
|
||||
"--language", "kotlin",
|
||||
"--no-format",
|
||||
"--out-dir", uniffiSourceDir.get().asFile.absolutePath,
|
||||
)
|
||||
inputs.file(hostRustLibrary)
|
||||
outputs.dir(uniffiSourceDir)
|
||||
}
|
||||
|
||||
val buildRustAndroid by tasks.registering(Exec::class) {
|
||||
group = "rust"
|
||||
description = "Builds pstr-android for every supported ABI with cargo-ndk."
|
||||
workingDir(repositoryRoot)
|
||||
doFirst { rustJniDir.get().asFile.mkdirs() }
|
||||
commandLine(
|
||||
"cargo", "ndk",
|
||||
"--target", "arm64-v8a",
|
||||
"--target", "x86_64",
|
||||
"--platform", "31",
|
||||
"--output-dir", rustJniDir.get().asFile.absolutePath,
|
||||
"build", "--release", "-p", "pstr-android", "--lib",
|
||||
)
|
||||
environment("CARGO_TARGET_DIR", repositoryRoot.resolve("target/android").absolutePath)
|
||||
inputs.files(fileTree(repositoryRoot.resolve("crates")) { include("**/*.rs", "**/Cargo.toml") })
|
||||
outputs.dir(rustJniDir)
|
||||
}
|
||||
|
||||
tasks.named("preBuild") {
|
||||
dependsOn(generateUniFfiBindings, buildRustAndroid, buildLibmpvAndroid)
|
||||
}
|
||||
|
||||
tasks.configureEach {
|
||||
if (name.contains("CMake", ignoreCase = true)) {
|
||||
dependsOn(buildRustAndroid, buildLibmpvAndroid)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.jna) { artifact { type = "aar" } }
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.runtime.livedata)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.material3.navigation.suite)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.androidx.work.testing)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version.set("1.5.0")
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
// UniFFI writes these; they are regenerated on every build.
|
||||
exclude { it.file.path.contains("${File.separator}generated${File.separator}") }
|
||||
// Vendored from rustls-platform-verifier. Restyling it would make the
|
||||
// next sync with upstream a merge conflict for no benefit.
|
||||
exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// the build for an undeclared dependency between the two tasks.
|
||||
tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") }
|
||||
.configureEach { dependsOn(generateUniFfiBindings) }
|
||||
@@ -0,0 +1,268 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.13.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.13.2)" variant="all" version="8.13.2">
|
||||
|
||||
<issue
|
||||
id="AndroidGradlePluginVersion"
|
||||
message="A newer version of com.android.application than 8.13.2 is available: 9.3.1"
|
||||
errorLine1="agp = "8.13.2""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="2"
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose:compose-bom than 2026.06.00 is available: 2026.06.01"
|
||||
errorLine1="compose-bom = "2026.06.00""
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="4"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.activity:activity-compose than 1.11.0 is available: 1.13.0"
|
||||
errorLine1="activity = "1.11.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="5"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.9.4 is available: 2.11.0"
|
||||
errorLine1="lifecycle = "2.9.4""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="6"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.lifecycle:lifecycle-viewmodel-compose than 2.9.4 is available: 2.11.0"
|
||||
errorLine1="lifecycle = "2.9.4""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="6"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.navigation:navigation-compose than 2.9.5 is available: 2.9.8"
|
||||
errorLine1="navigation = "2.9.5""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="7"
|
||||
column="14"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.work:work-runtime-ktx than 2.10.5 is available: 2.11.2"
|
||||
errorLine1="work = "2.10.5""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="8"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.work:work-testing than 2.10.5 is available: 2.11.2"
|
||||
errorLine1="work = "2.10.5""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="8"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose.material3.adaptive:adaptive than 1.1.0 is available: 1.2.0"
|
||||
errorLine1="adaptive = "1.1.0""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="9"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose.material3:material3-adaptive-navigation-suite than 1.1.0 is available: 1.4.0"
|
||||
errorLine1="adaptive = "1.1.0""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="9"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.core:core-ktx than 1.17.0 is available: 1.19.0"
|
||||
errorLine1="androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="19"
|
||||
column="64"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlin.android than 2.2.21 is available: 2.4.10"
|
||||
errorLine1="kotlin = "2.2.21""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="3"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.21 is available: 2.4.10"
|
||||
errorLine1="kotlin = "2.2.21""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="3"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of net.java.dev.jna:jna than 5.17.0 is available: 5.19.1"
|
||||
errorLine1="jna = "5.17.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="10"
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.robolectric:robolectric than 4.16 is available: 4.16.1"
|
||||
errorLine1="robolectric = "4.16""
|
||||
errorLine2=" ~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="12"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jlleitschuh.gradle.ktlint than 13.1.0 is available: 14.2.0"
|
||||
errorLine1="ktlint-plugin = "13.1.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="../gradle/libs.versions.toml"
|
||||
line="16"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ObsoleteSdkInt"
|
||||
message="Unnecessary; `SDK_INT` is always >= 31"
|
||||
errorLine1=" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt"
|
||||
line="294"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="AutoboxingStateCreation"
|
||||
message="Prefer `mutableIntStateOf` instead of `mutableStateOf`"
|
||||
errorLine1=" var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/playback/PlayerScreen.kt"
|
||||
line="707"
|
||||
column="47"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
|
||||
line="41"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" check(preferences.edit().remove(key).commit())"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/native/KeystoreSecretStore.kt"
|
||||
line="64"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="10"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="14"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTO_SKIP, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="25"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" set(value) { preferences.edit().putBoolean(KEY_AUTOPLAY, value).apply() }"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/io/narl/protonstream/settings/SettingsStore.kt"
|
||||
line="30"
|
||||
column="22"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
@@ -0,0 +1,98 @@
|
||||
# Keeps that exist so the instrumentation suite can run against the *release*
|
||||
# build. They are applied to the shipping APK, which is a real trade-off worth
|
||||
# stating plainly: the tested artifact is not byte-identical to an artifact
|
||||
# built without them.
|
||||
#
|
||||
# It is the better side of the trade. The alternative is to run instrumentation
|
||||
# against `debug` only, which leaves R8 — the step that has already broken this
|
||||
# app twice, in ways no debug run can reproduce — entirely uncovered. What is
|
||||
# kept here is a few kilobytes of framework plumbing containing no application
|
||||
# logic and revealing nothing about the app.
|
||||
#
|
||||
# Why these keeps are needed at all: the instrumentation APK is minified in a
|
||||
# separate R8 run that consumes the app's mapping file, and links against the
|
||||
# app's classes at runtime. That arrangement fails in two ways.
|
||||
#
|
||||
# 1. The mapping file carries renames but not R8's parameter-permutation
|
||||
# optimisation, so a permuted method resolves to the wrong descriptor from
|
||||
# the test APK.
|
||||
# 2. A class that only the test APK reaches is unreachable from the app's call
|
||||
# graph, so R8 removes it from the app APK — and the test APK does not
|
||||
# bundle it, because it is supposed to come from the app.
|
||||
|
||||
# The Kotlin runtime is where both failures land, because androidx.test is
|
||||
# itself Kotlin and shares the app's copy of the stdlib. Keeping it wholesale
|
||||
# rather than class by class is deliberate: each individual keep only moves the
|
||||
# crash to the next facade the runner happens to touch, which is a list nobody
|
||||
# can enumerate ahead of time. Three were hit before this rule replaced them:
|
||||
#
|
||||
# NoSuchMethodError: No static method f(Ljava/lang/Object;Ljava/lang/String;)V
|
||||
# in class Lo6/k; (o6.k = kotlin.jvm.internal.Intrinsics,
|
||||
# whose parameters R8 had permuted —
|
||||
# every Kotlin class calls it on entry,
|
||||
# so nothing ran at all)
|
||||
# NoClassDefFoundError: Lkotlin/LazyKt; (androidx.test.platform.io.TestDirCalculator)
|
||||
#
|
||||
# Cost is roughly a megabyte of dex against a ~100 MB APK that is almost
|
||||
# entirely native libraries.
|
||||
-keep class kotlin.** { *; }
|
||||
|
||||
# `AndroidJUnitRunner.onCreate` traces its own startup. Nothing in the app
|
||||
# reaches androidx.tracing, so R8 dropped it and the runner could not start:
|
||||
#
|
||||
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/tracing/Trace;
|
||||
# at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:307)
|
||||
-keep class androidx.tracing.** { *; }
|
||||
|
||||
# A third shape of the same problem, and the one worth understanding: the app
|
||||
# only ever touches WorkManager through its implementation, so R8 vertically
|
||||
# merged the abstract `androidx.work.WorkManager` into `WorkManagerImpl` and the
|
||||
# abstract type stopped existing. It is absent from mapping.txt entirely rather
|
||||
# than renamed. The download tests declare fields of the public types, so JUnit's
|
||||
# field scan could not even construct the test class:
|
||||
#
|
||||
# java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/work/WorkManager;
|
||||
# at org.junit.runners.model.TestClass.getSortedDeclaredFields(TestClass.java:77)
|
||||
#
|
||||
# Kept package-wide because the same merge can happen to any of the public API
|
||||
# types the tests name (WorkInfo, Data, Constraints), and finding out one crash
|
||||
# at a time costs a four-minute device round trip each.
|
||||
-keep class androidx.work.** { *; }
|
||||
|
||||
# Everything above is framework plumbing. The last group is app code, and it is
|
||||
# the one keep in this file that costs real fidelity: R8 no longer optimises
|
||||
# these classes in the shipping build, so the suite no longer proves R8 handles
|
||||
# them correctly. It is a narrow loss — the surfaces R8 has actually
|
||||
# broken here are the FFI ones, and those are kept for functional reasons in
|
||||
# proguard-rules.pro and stay under test.
|
||||
#
|
||||
# The trigger was `DownloadStateStore.clear()`. It is not dead code — AppViewModel
|
||||
# calls it — but it has exactly one call site, so R8 inlined it and dropped the
|
||||
# method. Only a caller that resolves it by name, which is to say a test, notices:
|
||||
#
|
||||
# java.lang.NoSuchMethodError: No virtual method clear()V
|
||||
# in class Lio/narl/protonstream/download/c;
|
||||
#
|
||||
# Package-wide rather than class-by-class: Kotlin's top-level functions live in
|
||||
# a synthesised `...Kt` facade that no source file names, so keeping the classes
|
||||
# a test imports still leaves it missing `DownloadCoordinatorKt`.
|
||||
-keep class io.narl.protonstream.download.** { *; }
|
||||
|
||||
# Same inlining, one layer out: the `wifiOnly` setter behind the download
|
||||
# constraint.
|
||||
# java.lang.NoSuchMethodError: No virtual method setWifiOnly(Z)V in class Li5/a;
|
||||
-keep class io.narl.protonstream.settings.SettingsStore { *; }
|
||||
|
||||
# The live cases drive the player directly, so the same inlining applies to the
|
||||
# playback classes they name.
|
||||
-keep class io.narl.protonstream.playback.** { *; }
|
||||
|
||||
# `runBlocking` — the live cases drive suspending bridge calls from a JUnit
|
||||
# thread. The app itself never blocks a thread on a coroutine, so R8 drops the
|
||||
# facade that holds it:
|
||||
#
|
||||
# java.lang.NoClassDefFoundError: Failed resolution of: Lkotlinx/coroutines/BuildersKt;
|
||||
#
|
||||
# Kept narrowly rather than as `kotlinx.coroutines.**`: that library is large and
|
||||
# on the app's hot paths, and pinning all of it would cost real optimisation.
|
||||
-keep class kotlinx.coroutines.BuildersKt** { *; }
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
-keep class uniffi.pstr_android.** { *; }
|
||||
# rustls-platform-verifier loads these classes from Rust through JNI, so R8
|
||||
# cannot infer that they are reachable from the Kotlin call graph.
|
||||
-keep class org.rustls.platformverifier.** { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-dontwarn com.sun.jna.**
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# UniFFI records are Kotlin data classes whose generic signatures and
|
||||
# annotations survive into the runtime; stripping them changes how the bindings
|
||||
# lift and lower structured types.
|
||||
-keepattributes Signature,*Annotation*,InnerClasses,EnclosingMethod
|
||||
|
||||
# JNA dispatches into callbacks reflectively by method name.
|
||||
-keepclassmembers class * implements com.sun.jna.Callback {
|
||||
<methods>;
|
||||
}
|
||||
|
||||
# The bridge calls back into these from Rust. Nothing in the Kotlin call graph
|
||||
# reaches their overrides, so R8 is free to rename or drop them.
|
||||
-keep class io.narl.protonstream.native.KeystoreSecretStore { *; }
|
||||
-keep class * implements uniffi.pstr_android.AndroidSecretStore { *; }
|
||||
-keep class * implements uniffi.pstr_android.DownloadObserver { *; }
|
||||
@@ -0,0 +1,11 @@
|
||||
# Applied only to the instrumentation APK when androidTest runs against a
|
||||
# minified build (ANDROID_TEST_BUILD_TYPE=release). The tests exist to prove the
|
||||
# *app's* R8 configuration is right, so nothing here may relax the app's rules —
|
||||
# it only keeps the test harness itself intact.
|
||||
-dontobfuscate
|
||||
-keep class io.narl.protonstream.** { *; }
|
||||
-keep class androidx.test.** { *; }
|
||||
-dontwarn androidx.test.**
|
||||
-keepclasseswithmembers class * {
|
||||
@org.junit.Test <methods>;
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.SynchronousExecutor
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* What actually reaches WorkManager. The unit tests cover the pure helpers; this
|
||||
* covers the enqueue itself — the input data (which is persisted unencrypted, so
|
||||
* it must carry no secret), the constraints, and the unique-work identity that
|
||||
* pause and resume depend on.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DownloadCoordinatorInstrumentedTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var workManager: WorkManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||
context,
|
||||
Configuration.Builder().setExecutor(SynchronousExecutor()).build(),
|
||||
)
|
||||
workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWork().result.get()
|
||||
DownloadStateStore(context).clear()
|
||||
}
|
||||
|
||||
private fun download(linkId: String = "link-1", shareId: String = "share-1") = RetainedDownload(
|
||||
shareId = shareId,
|
||||
volumeId = "volume-1",
|
||||
linkId = linkId,
|
||||
label = "S01E01.mkv",
|
||||
)
|
||||
|
||||
private fun infoFor(download: RetainedDownload): WorkInfo? =
|
||||
workManager.getWorkInfosForUniqueWork(
|
||||
DownloadCoordinator.workName(download.shareId, download.linkId),
|
||||
).get().firstOrNull()
|
||||
|
||||
@Test
|
||||
fun resumingEnqueuesUniqueWorkAndRetainsAQueuedRecord() {
|
||||
val download = download()
|
||||
DownloadCoordinator.resume(context, download)
|
||||
|
||||
assertNotNull(infoFor(download))
|
||||
assertEquals(
|
||||
RetainedDownload.STATUS_QUEUED,
|
||||
DownloadStateStore(context).get("share-1", "link-1")?.status,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags are persisted in `androidx.work.workdb` unencrypted and are what the
|
||||
* UI queries and what cancel-by-share matches on. Pinning the exact set is
|
||||
* what makes an accidental addition — a fragment, a URL — visible in review.
|
||||
*/
|
||||
@Test
|
||||
fun theWorkCarriesExactlyTheExpectedTags() {
|
||||
val download = download()
|
||||
DownloadCoordinator.resume(context, download)
|
||||
|
||||
val tags = infoFor(download)?.tags.orEmpty()
|
||||
assertTrue(DownloadCoordinator.TAG in tags)
|
||||
assertTrue(DownloadCoordinator.shareTag("share-1") in tags)
|
||||
assertTrue("${DownloadCoordinator.EPISODE_TAG}S01E01.mkv" in tags)
|
||||
// The remaining tag is WorkManager's own worker class name.
|
||||
assertEquals(4, tags.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWifiOnlySettingReachesTheConstraint() {
|
||||
// Documents B29: the constraint is read at enqueue time, so work already
|
||||
// queued keeps whatever was set when it was queued. When B29 is fixed,
|
||||
// this test grows a case for flipping the toggle after enqueue.
|
||||
SettingsStore(context).wifiOnly = true
|
||||
assertEquals(NetworkType.UNMETERED, requiredNetworkType(SettingsStore(context).wifiOnly))
|
||||
SettingsStore(context).wifiOnly = false
|
||||
assertEquals(NetworkType.CONNECTED, requiredNetworkType(SettingsStore(context).wifiOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseCancelsTheWorkAndRecordsThePausedStatus() {
|
||||
val download = download()
|
||||
DownloadCoordinator.resume(context, download)
|
||||
DownloadCoordinator.pause(context, download)
|
||||
|
||||
assertTrue(infoFor(download)?.state?.isFinished ?: false)
|
||||
assertEquals(
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
DownloadStateStore(context).get("share-1", "link-1")?.status,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingOneShareLeavesAnotherSharesWorkAlone() {
|
||||
val first = download(linkId = "a", shareId = "share-1")
|
||||
val second = download(linkId = "b", shareId = "share-2")
|
||||
DownloadCoordinator.resume(context, first)
|
||||
DownloadCoordinator.resume(context, second)
|
||||
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag("share-1")).result.get()
|
||||
|
||||
assertTrue(infoFor(first)?.state?.isFinished ?: false)
|
||||
assertEquals(WorkInfo.State.ENQUEUED, infoFor(second)?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun twoEpisodesOfOneShareEnqueueSeparately() {
|
||||
DownloadCoordinator.resume(context, download(linkId = "a"))
|
||||
DownloadCoordinator.resume(context, download(linkId = "b"))
|
||||
|
||||
assertEquals(2, workManager.getWorkInfosByTag(DownloadCoordinator.TAG).get().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package io.narl.protonstream.live
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.view.Surface
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.AndroidSecretStore
|
||||
import uniffi.pstr_android.DownloadObserver
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
* The cases `docs/ANDROID.md` requires that cannot be answered without a real
|
||||
* share: that a link resolves into a playable catalog, that libmpv actually
|
||||
* decodes the decrypted stream, that a cancelled download leaves a resumable
|
||||
* `.part`, and that a resume position survives a restart.
|
||||
*
|
||||
* The link is an instrumentation argument rather than a constant or a file, so
|
||||
* the secret never enters the repository:
|
||||
*
|
||||
* ```
|
||||
* -Pandroid.testInstrumentationRunnerArguments.pstr.shareUrl=...
|
||||
* ```
|
||||
*
|
||||
* Without it every test here is skipped by assumption, and
|
||||
* `scripts/android-acceptance.sh` reports the matrix as incomplete.
|
||||
*
|
||||
* Read-only against the share by construction: nothing here uploads, renames or
|
||||
* deletes anything remote. It writes only to this test's own temporary
|
||||
* directories.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LiveShareTest {
|
||||
@Test
|
||||
fun theShareResolvesIntoAPlayableCatalog() {
|
||||
val episodes = episodes()
|
||||
assertTrue("the crawl found no playable file in the share", episodes.isNotEmpty())
|
||||
// A file with no size cannot be streamed: the block layer needs the
|
||||
// length to place block boundaries at all.
|
||||
val sized = episodes.filter { (it.size ?: 0uL) > 0uL }
|
||||
assertTrue("no episode in the share reported a size", sized.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingAnEpisodeYieldsASeekableRevision() {
|
||||
val episode = playable()
|
||||
val stream = runBlocking {
|
||||
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||
}
|
||||
try {
|
||||
assertTrue("the opened revision has no length", stream.size() > 0uL)
|
||||
// Without a revision id the block cache cannot tell one revision of
|
||||
// a file from another, and a stale block would be served as fresh.
|
||||
assertTrue("the opened revision has no id", stream.revisionId().isNotBlank())
|
||||
} finally {
|
||||
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole pipeline in one assertion: Proton block storage, decryption,
|
||||
* the Rust stream C ABI, the JNI adapter and libmpv's demuxer. If the
|
||||
* position advances, real decrypted bytes reached the decoder.
|
||||
*/
|
||||
@Test
|
||||
fun libmpvDecodesTheDecryptedStream() {
|
||||
val episode = playable()
|
||||
val texture = SurfaceTexture(0).apply { setDefaultBufferSize(1280, 720) }
|
||||
val surface = Surface(texture)
|
||||
val host = NativeMpvHost()
|
||||
val key = "${episode.shareId}:${episode.linkId}"
|
||||
try {
|
||||
host.attachSurface(surface)
|
||||
val stream = runBlocking {
|
||||
engine.openStream(episode.shareId, episode.volumeId, episode.linkId)
|
||||
}
|
||||
runBlocking { host.play(key, stream.nativeHandle(), stream.size()) }
|
||||
host.setMuted(true)
|
||||
host.setPaused(false)
|
||||
|
||||
val playing = waitFor(PLAYBACK_TIMEOUT_MS) { host.state.value.position > 0.0 }
|
||||
assertTrue(
|
||||
"libmpv never advanced past zero: ${host.state.value}",
|
||||
playing,
|
||||
)
|
||||
// One host serves every episode in turn, so a reading that does not
|
||||
// name this one would resume the wrong file.
|
||||
assertEquals(key, host.state.value.media)
|
||||
assertTrue("libmpv reported no duration", host.state.value.duration > 0.0)
|
||||
} finally {
|
||||
host.stop()
|
||||
host.detachSurface()
|
||||
host.close()
|
||||
surface.release()
|
||||
texture.release()
|
||||
runBlocking { engine.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation is polled between whole Proton blocks precisely so the bytes
|
||||
* already written stay usable. This checks the retained `.part` is counted
|
||||
* as partial rather than as a finished file, and that resuming continues
|
||||
* from it instead of starting over.
|
||||
*/
|
||||
@Test
|
||||
fun aCancelledDownloadLeavesAResumablePartFile() {
|
||||
val episode = playable()
|
||||
val firstRun = CancelAfter(bytes = RESUME_THRESHOLD_BYTES)
|
||||
runCatching {
|
||||
runBlocking {
|
||||
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, firstRun)
|
||||
}
|
||||
}
|
||||
assumeTrue(
|
||||
"the episode finished before cancellation could be observed; it is too small for this case",
|
||||
!firstRun.completed,
|
||||
)
|
||||
assertTrue("the download reported no progress at all", firstRun.observed > 0L)
|
||||
|
||||
val retained = engine.storageUsage()
|
||||
assertTrue("a cancelled download retained no bytes", retained.partialBytes > 0uL)
|
||||
assertEquals("a cancelled download counted as a finished file", 0uL, retained.offlineCount)
|
||||
|
||||
// Resuming must pick up where the .part ended rather than at zero.
|
||||
val secondRun = CancelAfter(bytes = retained.partialBytes.toLong() + RESUME_THRESHOLD_BYTES)
|
||||
runCatching {
|
||||
runBlocking {
|
||||
engine.downloadEpisode(episode.shareId, episode.volumeId, episode.linkId, secondRun)
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
"resuming restarted from zero: first reading was ${secondRun.firstReading}, " +
|
||||
"but ${retained.partialBytes} bytes were already retained",
|
||||
secondRun.firstReading >= retained.partialBytes.toLong(),
|
||||
)
|
||||
|
||||
runBlocking { engine.removeOfflineEpisode(episode.shareId, episode.linkId) }
|
||||
assertEquals(
|
||||
"removing the episode left partial bytes behind",
|
||||
0uL,
|
||||
engine.storageUsage().partialBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/** What the viewer sees as "continue watching" after the app is killed. */
|
||||
@Test
|
||||
fun aResumePositionSurvivesReopeningTheCatalog() {
|
||||
val episode = playable()
|
||||
engine.saveWatchState(episode.shareId, episode.linkId, RESUME_POSITION, null, false)
|
||||
|
||||
val reopened = AndroidEngine(paths, secrets)
|
||||
val state = reopened.watchState(episode.shareId, episode.linkId)
|
||||
assertNotNull("the resume position did not survive a reopen", state)
|
||||
assertEquals(RESUME_POSITION, state!!.positionSecs, 0.001)
|
||||
assertEquals(false, state.watched)
|
||||
|
||||
// The catalog view is what the UI reads, so it has to agree.
|
||||
val listed = reopened.library(null)
|
||||
.flatMap { it.seasons }
|
||||
.flatMap { it.episodes }
|
||||
.first { it.linkId == episode.linkId }
|
||||
assertEquals(RESUME_POSITION, listed.resumeAt ?: -1.0, 0.001)
|
||||
}
|
||||
|
||||
/** Cancels once the download has written enough to prove a resume. */
|
||||
private class CancelAfter(private val bytes: Long) : DownloadObserver {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val progress = AtomicLong(0)
|
||||
private val first = AtomicLong(-1)
|
||||
|
||||
val observed: Long get() = progress.get()
|
||||
val firstReading: Long get() = first.get()
|
||||
val completed: Boolean get() = !cancelled.get()
|
||||
|
||||
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||
first.compareAndSet(-1, downloaded.toLong())
|
||||
progress.set(downloaded.toLong())
|
||||
if (downloaded.toLong() >= bytes) cancelled.set(true)
|
||||
}
|
||||
|
||||
override fun isCancelled(): Boolean = cancelled.get()
|
||||
}
|
||||
|
||||
private fun episodes(): List<EpisodeRecord> =
|
||||
engine.library(null).flatMap { it.seasons }.flatMap { it.episodes }
|
||||
|
||||
private fun playable(): EpisodeRecord =
|
||||
episodes().firstOrNull { (it.size ?: 0uL) > 0uL }
|
||||
?: throw AssertionError("the share has no sized file to play")
|
||||
|
||||
private fun waitFor(timeoutMs: Long, condition: () -> Boolean): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return true
|
||||
Thread.sleep(200)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Stands in for Keystore, so a bridge failure cannot be blamed on crypto. */
|
||||
private class MemorySecretStore : AndroidSecretStore {
|
||||
private val entries = ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun set(key: String, value: String) {
|
||||
entries[key] = value
|
||||
}
|
||||
|
||||
override fun get(key: String): String? = entries[key]
|
||||
|
||||
override fun delete(key: String) {
|
||||
entries.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHARE_URL_ARGUMENT = "pstr.shareUrl"
|
||||
private const val SHARE_PASSWORD_ARGUMENT = "pstr.sharePassword"
|
||||
|
||||
/** Long enough for a cold connection, a crawl and a first block read. */
|
||||
private const val PLAYBACK_TIMEOUT_MS = 60_000L
|
||||
|
||||
/** Enough written bytes to prove a resume, small enough to stay quick. */
|
||||
private const val RESUME_THRESHOLD_BYTES = 4L * 1024 * 1024
|
||||
|
||||
private const val RESUME_POSITION = 137.5
|
||||
|
||||
private lateinit var paths: AndroidPaths
|
||||
private lateinit var secrets: AndroidSecretStore
|
||||
private lateinit var engine: AndroidEngine
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun openTheShare() {
|
||||
System.loadLibrary("pstr_android")
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
val url = arguments.getString(SHARE_URL_ARGUMENT)
|
||||
assumeTrue(
|
||||
"set -Pandroid.testInstrumentationRunnerArguments.$SHARE_URL_ARGUMENT to run the live cases",
|
||||
!url.isNullOrBlank(),
|
||||
)
|
||||
val password = arguments.getString(SHARE_PASSWORD_ARGUMENT)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val root = File.createTempFile("pstr-live", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
paths = AndroidPaths(
|
||||
File(root, "config").absolutePath,
|
||||
File(root, "data").absolutePath,
|
||||
File(root, "cache").absolutePath,
|
||||
)
|
||||
secrets = MemorySecretStore()
|
||||
engine = AndroidEngine(paths, secrets)
|
||||
engine.addShare("acceptance", url!!, password)
|
||||
runBlocking { engine.crawl(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.security.KeyStore
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import uniffi.pstr_android.BridgeException
|
||||
|
||||
/**
|
||||
* Runs on a device because Android Keystore has no host implementation — which
|
||||
* is why nothing pinned this before. The share fragment and any custom link
|
||||
* password are the only user secrets the app holds, and losing the key makes
|
||||
* every share unopenable, so the failure modes matter as much as the happy path.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class KeystoreSecretStoreTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var store: KeystoreSecretStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
clearPreferences()
|
||||
store = KeystoreSecretStore(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
clearPreferences()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecretSurvivesARoundTrip() {
|
||||
store.set("share-1", "correct horse battery staple")
|
||||
assertEquals("correct horse battery staple", store.get("share-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAbsentSecretIsNullRatherThanEmpty() {
|
||||
assertNull(store.get("never-written"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesTheSecret() {
|
||||
store.set("share-1", "value")
|
||||
store.delete("share-1")
|
||||
assertNull(store.get("share-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aUnicodeSecretIsNotMangled() {
|
||||
val value = "パスワード・🎬・ü"
|
||||
store.set("share-1", value)
|
||||
assertEquals(value, store.get("share-1"))
|
||||
}
|
||||
|
||||
/**
|
||||
* GCM is catastrophic under IV reuse: two secrets encrypted with the same
|
||||
* key and IV leak the XOR of their plaintexts. `Cipher.init(ENCRYPT_MODE)`
|
||||
* is what generates a fresh one, so this pins that the code never pins it.
|
||||
*/
|
||||
@Test
|
||||
fun everyWriteUsesAFreshInitialisationVector() {
|
||||
store.set("share-1", "same plaintext")
|
||||
val first = rawPayload("share-1")
|
||||
store.set("share-1", "same plaintext")
|
||||
val second = rawPayload("share-1")
|
||||
|
||||
val firstIv = first.copyOfRange(0, IV_SIZE)
|
||||
val secondIv = second.copyOfRange(0, IV_SIZE)
|
||||
assertNotEquals(Base64.encodeToString(firstIv, Base64.NO_WRAP), Base64.encodeToString(secondIv, Base64.NO_WRAP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theStoredFormIsNotThePlaintext() {
|
||||
store.set("share-1", "correct horse battery staple")
|
||||
val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString("share-1", null)
|
||||
assertTrue(stored != null && !stored.contains("correct horse"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A tampered payload must not decrypt, and must fail as a [BridgeException].
|
||||
* An `AEADBadTagException` crossing the FFI has no mapping on the Rust side,
|
||||
* and an unmapped callback exception panics the calling thread (B18).
|
||||
*/
|
||||
@Test
|
||||
fun aTamperedPayloadFailsToDecrypt() {
|
||||
store.set("share-1", "value")
|
||||
val payload = rawPayload("share-1")
|
||||
payload[payload.size - 1] = (payload[payload.size - 1].toInt() xor 0x01).toByte()
|
||||
writeRawPayload("share-1", payload)
|
||||
|
||||
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTruncatedPayloadIsRejectedBeforeDecryption() {
|
||||
store.set("share-1", "value")
|
||||
writeRawPayload("share-1", rawPayload("share-1").copyOfRange(0, IV_SIZE))
|
||||
|
||||
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-side half of B18: this is what a lockscreen change or a device
|
||||
* restore does to the key. It must fail the read rather than the process,
|
||||
* and the store must still accept a re-entered secret afterwards — which is
|
||||
* what the shares screen's "Re-enter link" depends on.
|
||||
*/
|
||||
@Test
|
||||
fun aLostKeyFailsTheReadRatherThanTheProcess() {
|
||||
store.set("share-1", "value")
|
||||
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
|
||||
|
||||
assertThrows(BridgeException::class.java) { store.get("share-1") }
|
||||
|
||||
// The store must still be usable for a re-entered secret.
|
||||
store.set("share-1", "re-entered")
|
||||
assertEquals("re-entered", store.get("share-1"))
|
||||
}
|
||||
|
||||
private fun rawPayload(key: String): ByteArray {
|
||||
val encoded = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString(key, null) ?: error("no payload for $key")
|
||||
return Base64.decode(encoded, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun writeRawPayload(key: String, payload: ByteArray) {
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(key, Base64.encodeToString(payload, Base64.NO_WRAP))
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun clearPreferences() {
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "proton_stream_secrets"
|
||||
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
|
||||
const val IV_SIZE = 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.AndroidSecretStore
|
||||
import uniffi.pstr_android.chapterPlan
|
||||
import uniffi.pstr_android.skipOffer
|
||||
|
||||
/**
|
||||
* The first thing that crosses the UniFFI boundary on a real device. It is
|
||||
* deliberately offline: it proves the ABI, the generated bindings, the callback
|
||||
* interface and the SQLite open all work, without needing a share.
|
||||
*
|
||||
* If this fails and the Rust workspace tests pass, the fault is in packaging —
|
||||
* a missing ABI in `jniLibs`, a stale binding, or an R8 rule (run it with
|
||||
* `ANDROID_TEST_BUILD_TYPE=release` to check the shipped configuration).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UniFfiBoundaryTest {
|
||||
/** Stands in for Keystore so a bridge failure cannot be blamed on crypto. */
|
||||
private class MemorySecretStore : AndroidSecretStore {
|
||||
val entries = ConcurrentHashMap<String, String>()
|
||||
var reads = 0
|
||||
private set
|
||||
|
||||
override fun set(key: String, value: String) {
|
||||
entries[key] = value
|
||||
}
|
||||
|
||||
override fun get(key: String): String? {
|
||||
reads += 1
|
||||
return entries[key]
|
||||
}
|
||||
|
||||
override fun delete(key: String) {
|
||||
entries.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun engine(secrets: AndroidSecretStore = MemorySecretStore()): AndroidEngine {
|
||||
val root = File.createTempFile("pstr-bridge", "").let {
|
||||
it.delete()
|
||||
it.mkdirs()
|
||||
it
|
||||
}
|
||||
return AndroidEngine(
|
||||
AndroidPaths(
|
||||
File(root, "config").absolutePath,
|
||||
File(root, "data").absolutePath,
|
||||
File(root, "cache").absolutePath,
|
||||
),
|
||||
secrets,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theEngineOpensAgainstEmptyDirectories() {
|
||||
val engine = engine()
|
||||
assertTrue(engine.shares().isEmpty())
|
||||
assertTrue(engine.library(null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storageUsageReportsZeroOnAFreshInstall() {
|
||||
val usage = engine().storageUsage()
|
||||
assertNotNull(usage)
|
||||
assertEquals(0UL, usage.offlineBytes)
|
||||
assertEquals(0UL, usage.partialBytes)
|
||||
assertEquals(0UL, usage.offlineCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataSettingsAreReadableAndDefaultToDisabled() {
|
||||
// Enrichment is opt-in; a default of enabled would send titles to a
|
||||
// third party before the viewer has agreed to it.
|
||||
assertEquals(false, engine().metadataSettings().enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecondEngineReopensTheSameCatalog() {
|
||||
val secrets = MemorySecretStore()
|
||||
engine(secrets).shares()
|
||||
// Reopening is the process-restart path; a schema or lock fault shows up
|
||||
// here rather than on a user's device.
|
||||
assertTrue(engine(secrets).shares().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapter classification is shared Rust that desktop already pins
|
||||
* (`docs/BUGS.md` B12). This runs the same code through the bindings, so a
|
||||
* lowering fault in the record types cannot pass unnoticed.
|
||||
*/
|
||||
@Test
|
||||
fun chapterPlanningCrossesTheBoundaryIntact() {
|
||||
val plan = chapterPlan(emptyList(), 1560.0)
|
||||
assertNotNull(plan)
|
||||
assertNull(skipOffer(plan, 12.0))
|
||||
}
|
||||
|
||||
companion object {
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun loadLibrary() {
|
||||
System.loadLibrary("pstr_android")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<application
|
||||
android:name=".ProtonStreamApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ProtonStream">
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
android:name=".playback.PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,232 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Third-Party Notices
|
||||
|
||||
The proton-stream Android distribution combines open-source components. The
|
||||
complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
||||
|
||||
| Component | License |
|
||||
|---|---|
|
||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||
| Kotlin | Apache-2.0 |
|
||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||
| UniFFI | MPL-2.0 |
|
||||
| proton-stream reusable Rust crates | MIT |
|
||||
|
||||
Before release, replace this dependency summary with an inventory generated
|
||||
from the resolved Cargo and Gradle dependency graphs and package every required
|
||||
license text. A bundled libmpv release must identify the exact source revision,
|
||||
patches, and build configuration, and make the corresponding source available
|
||||
under the GPL.
|
||||
@@ -0,0 +1,29 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(pstr_mpv LANGUAGES CXX)
|
||||
|
||||
if(NOT DEFINED PSTR_MPV_ROOT)
|
||||
message(FATAL_ERROR "PSTR_MPV_ROOT must point at the staged Android libmpv tree")
|
||||
endif()
|
||||
if(NOT DEFINED PSTR_RUST_JNI_ROOT)
|
||||
message(FATAL_ERROR "PSTR_RUST_JNI_ROOT must point at cargo-ndk output")
|
||||
endif()
|
||||
|
||||
add_library(pstr_mpv SHARED pstr_mpv.cpp)
|
||||
add_library(mpv SHARED IMPORTED)
|
||||
set_target_properties(mpv PROPERTIES
|
||||
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
|
||||
# libmpv links FFmpeg dynamically and does not re-export it, so registering the
|
||||
# JavaVM that MediaCodec needs means linking libavcodec ourselves. It is already
|
||||
# packaged in the same ABI directory as part of libmpv's dependency closure.
|
||||
add_library(avcodec SHARED IMPORTED)
|
||||
set_target_properties(avcodec PROPERTIES
|
||||
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libavcodec.so")
|
||||
|
||||
target_compile_features(pstr_mpv PRIVATE cxx_std_20)
|
||||
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
|
||||
target_include_directories(pstr_mpv PRIVATE "${PSTR_MPV_ROOT}/include")
|
||||
# Link by soname rather than by absolute build path. Both libraries are
|
||||
# packaged into the same ABI directory in the APK.
|
||||
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
|
||||
target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
|
||||
target_link_libraries(pstr_mpv PRIVATE mpv avcodec android EGL log)
|
||||
@@ -0,0 +1,900 @@
|
||||
#include "pstr_mpv.h"
|
||||
|
||||
#include <EGL/egl.h>
|
||||
#include <android/log.h>
|
||||
#include <android/native_window.h>
|
||||
#include <android/native_window_jni.h>
|
||||
#include <jni.h>
|
||||
#include <mpv/client.h>
|
||||
#include <mpv/render_gl.h>
|
||||
#include <mpv/stream_cb.h>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/jni.h>
|
||||
}
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kProtocol = "pstr";
|
||||
|
||||
struct StreamCookie {
|
||||
uint64_t handle;
|
||||
int64_t size;
|
||||
int64_t position = 0;
|
||||
std::atomic<bool> cancelled{false};
|
||||
};
|
||||
|
||||
template <typename Result, typename Function>
|
||||
Result c_boundary(Result failure, Function &&function) noexcept {
|
||||
try {
|
||||
return std::forward<Function>(function)();
|
||||
} catch (...) {
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
void c_boundary(Function &&function) noexcept {
|
||||
try {
|
||||
std::forward<Function>(function)();
|
||||
} catch (...) {
|
||||
// C and JNI callers cannot receive C++ exceptions.
|
||||
}
|
||||
}
|
||||
|
||||
int64_t stream_read(void *opaque, char *buffer, uint64_t length) noexcept {
|
||||
return c_boundary<int64_t>(-1, [=] {
|
||||
auto *stream = static_cast<StreamCookie *>(opaque);
|
||||
if (!stream || !buffer) return int64_t{-1};
|
||||
if (stream->cancelled.load(std::memory_order_relaxed)) return int64_t{-1};
|
||||
const auto remaining = stream->size - stream->position;
|
||||
if (remaining <= 0) return int64_t{0};
|
||||
const auto requested = static_cast<size_t>(std::min<uint64_t>(length, remaining));
|
||||
const int64_t read = pstr_android_stream_read(
|
||||
stream->handle, static_cast<uint64_t>(stream->position), buffer, requested);
|
||||
if (read > 0) stream->position += read;
|
||||
return read;
|
||||
});
|
||||
}
|
||||
|
||||
int64_t stream_seek(void *opaque, int64_t offset) noexcept {
|
||||
return c_boundary<int64_t>(MPV_ERROR_GENERIC, [=] {
|
||||
auto *stream = static_cast<StreamCookie *>(opaque);
|
||||
if (!stream || offset < 0 || offset > stream->size) {
|
||||
return int64_t{MPV_ERROR_GENERIC};
|
||||
}
|
||||
stream->position = offset;
|
||||
stream->cancelled.store(false, std::memory_order_relaxed);
|
||||
return offset;
|
||||
});
|
||||
}
|
||||
|
||||
int64_t stream_size(void *opaque) noexcept {
|
||||
return c_boundary<int64_t>(MPV_ERROR_GENERIC, [=] {
|
||||
auto *stream = static_cast<StreamCookie *>(opaque);
|
||||
return stream ? stream->size : int64_t{MPV_ERROR_GENERIC};
|
||||
});
|
||||
}
|
||||
|
||||
/// mpv wants its request slot back — a seek, or a teardown.
|
||||
///
|
||||
/// The flag alone only stops the *next* read; the one already parked in a 4 MiB
|
||||
/// block fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits
|
||||
/// out the whole fetch while joining the demuxer thread.
|
||||
void stream_cancel(void *opaque) noexcept {
|
||||
c_boundary([=] {
|
||||
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
||||
stream->cancelled.store(true, std::memory_order_relaxed);
|
||||
pstr_android_stream_cancel(stream->handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Releases the Rust stream behind the file mpv has finished with.
|
||||
///
|
||||
/// Ownership sits here rather than in Kotlin because this is the one moment
|
||||
/// that is *defined* to be after mpv's last read: `loadfile` is asynchronous, so
|
||||
/// a Kotlin-side release issued when the next load returns can land while the
|
||||
/// outgoing demuxer is still reading — and, if the release names the handle the
|
||||
/// registry has already swapped, takes the incoming episode's stream with it.
|
||||
void stream_close(void *opaque) noexcept {
|
||||
c_boundary([=] {
|
||||
std::unique_ptr<StreamCookie> cookie(static_cast<StreamCookie *>(opaque));
|
||||
if (cookie) pstr_android_stream_release(cookie->handle);
|
||||
});
|
||||
}
|
||||
|
||||
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
|
||||
return c_boundary<int>(MPV_ERROR_LOADING_FAILED, [=]() -> int {
|
||||
if (!info) return MPV_ERROR_LOADING_FAILED;
|
||||
constexpr std::string_view prefix = "pstr://";
|
||||
const std::string_view value(uri == nullptr ? "" : uri);
|
||||
if (!value.starts_with(prefix)) return MPV_ERROR_LOADING_FAILED;
|
||||
uint64_t handle = 0;
|
||||
const auto token = value.substr(prefix.size());
|
||||
const auto result = std::from_chars(token.data(), token.data() + token.size(), handle);
|
||||
if (result.ec != std::errc{} || result.ptr != token.data() + token.size()) {
|
||||
return MPV_ERROR_LOADING_FAILED;
|
||||
}
|
||||
const int64_t size = pstr_android_stream_size(handle);
|
||||
if (size < 0) return MPV_ERROR_LOADING_FAILED;
|
||||
std::unique_ptr<StreamCookie> cookie(new StreamCookie{handle, size});
|
||||
info->cookie = cookie.get();
|
||||
info->read_fn = stream_read;
|
||||
info->seek_fn = stream_seek;
|
||||
info->size_fn = stream_size;
|
||||
info->close_fn = stream_close;
|
||||
info->cancel_fn = stream_cancel;
|
||||
cookie.release();
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
void *resolve_gl(void *, const char *name) noexcept {
|
||||
return c_boundary<void *>(nullptr, [=] {
|
||||
return reinterpret_cast<void *>(eglGetProcAddress(name));
|
||||
});
|
||||
}
|
||||
|
||||
/// Why the open file ended, mirroring `pstr_player::EndReason` so that the two
|
||||
/// clients agree on what "finished" means. Only [Eof] is an episode the viewer
|
||||
/// watched to the end; the rest are a stop, a shutdown or a failure, and none of
|
||||
/// them may mark anything watched or advance to the next episode.
|
||||
enum class EndReason : int {
|
||||
None = 0,
|
||||
Eof = 1,
|
||||
Stopped = 2,
|
||||
Quit = 3,
|
||||
Failed = 4,
|
||||
Other = 5,
|
||||
};
|
||||
|
||||
EndReason end_reason_of(int reason) {
|
||||
switch (reason) {
|
||||
case MPV_END_FILE_REASON_EOF: return EndReason::Eof;
|
||||
case MPV_END_FILE_REASON_STOP: return EndReason::Stopped;
|
||||
case MPV_END_FILE_REASON_QUIT: return EndReason::Quit;
|
||||
case MPV_END_FILE_REASON_ERROR: return EndReason::Failed;
|
||||
default: return EndReason::Other;
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackState {
|
||||
double position = 0.0;
|
||||
double duration = 0.0;
|
||||
double volume = 100.0;
|
||||
bool paused = true;
|
||||
bool muted = false;
|
||||
bool ended = false;
|
||||
EndReason end_reason = EndReason::None;
|
||||
/// mpv has run out of buffered data and stopped to refill. Distinct from
|
||||
/// [paused], which is the viewer's doing: a frozen picture that nothing
|
||||
/// explains is indistinguishable from a hung app.
|
||||
bool buffering = false;
|
||||
/// How full the demuxer cache is while [buffering], 0–100.
|
||||
double cache_percent = 0.0;
|
||||
/// Between a seek being issued and playback actually resuming. The seek bar
|
||||
/// has moved but the picture has not.
|
||||
bool seeking = false;
|
||||
/// True between `loadfile` being issued and mpv acknowledging it with
|
||||
/// `START_FILE`. Everything mpv reports in that window still describes the
|
||||
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
|
||||
/// and must not be attributed to the file being loaded.
|
||||
bool loading = false;
|
||||
/// The picture's display size, once mpv has decoded enough to know it.
|
||||
/// Zero until then, and the aspect Picture-in-Picture is given.
|
||||
double video_width = 0.0;
|
||||
double video_height = 0.0;
|
||||
/// Which loaded file this state describes. Bumped by every load, so a
|
||||
/// reader can tell "episode two, second zero" from a leftover reading of
|
||||
/// episode one — which is the difference between resuming an episode where
|
||||
/// the viewer left it and resuming it where the *previous* one ended.
|
||||
int64_t generation = 0;
|
||||
};
|
||||
|
||||
class Player {
|
||||
public:
|
||||
Player() {
|
||||
mpv_ = mpv_create();
|
||||
if (!mpv_) return;
|
||||
option("config", "no");
|
||||
option("vo", "libmpv");
|
||||
option("force-window", "no");
|
||||
option("video-timing-offset", "0");
|
||||
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
||||
// 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,
|
||||
// and the SurfaceView — when there is one at all — attaches later than
|
||||
// the load. `auto-safe` picks them anyway and every file opens with
|
||||
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
||||
// falling back. The copy variants decode in hardware and hand back
|
||||
// ordinary frames, which is what this render path can actually use.
|
||||
option("hwdec", "auto-copy-safe");
|
||||
option("cache", "yes");
|
||||
option("cache-secs", "30");
|
||||
option("demuxer-readahead-secs", "30");
|
||||
option("demuxer-max-bytes", "50331648");
|
||||
option("audio-focus", "no"); // Android AudioManager owns focus.
|
||||
if (mpv_stream_cb_add_ro(mpv_, kProtocol, nullptr, stream_open) < 0 ||
|
||||
mpv_initialize(mpv_) < 0) {
|
||||
mpv_terminate_destroy(mpv_);
|
||||
mpv_ = nullptr;
|
||||
return;
|
||||
}
|
||||
observe("time-pos", 1, MPV_FORMAT_DOUBLE);
|
||||
observe("duration", 2, MPV_FORMAT_DOUBLE);
|
||||
observe("pause", 3, MPV_FORMAT_FLAG);
|
||||
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
||||
observe("mute", 5, MPV_FORMAT_FLAG);
|
||||
observe("video-params/dw", 6, MPV_FORMAT_INT64);
|
||||
observe("video-params/dh", 7, MPV_FORMAT_INT64);
|
||||
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
|
||||
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
|
||||
// Errors mpv reports about the file itself, rather than about the
|
||||
// request that opened it. Without these a failed demux is silent.
|
||||
mpv_request_log_messages(mpv_, "error");
|
||||
events_ = std::thread([this] { event_loop(); });
|
||||
renderer_ = std::thread([this] { render_loop(); });
|
||||
}
|
||||
|
||||
~Player() noexcept {
|
||||
// Destruction runs from JNI. Keep failures from std::thread primitives
|
||||
// inside C++, even on runtimes that report a join error.
|
||||
c_boundary([this] {
|
||||
running_.store(false);
|
||||
render_cv_.notify_all();
|
||||
if (mpv_) mpv_wakeup(mpv_);
|
||||
});
|
||||
c_boundary([this] { if (events_.joinable()) events_.join(); });
|
||||
c_boundary([this] { if (renderer_.joinable()) renderer_.join(); });
|
||||
c_boundary([this] { if (mpv_) mpv_terminate_destroy(mpv_); });
|
||||
}
|
||||
|
||||
bool valid() const { return mpv_ != nullptr; }
|
||||
|
||||
bool attach(JNIEnv *env, jobject surface) {
|
||||
if (!env || !surface) return false;
|
||||
ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
|
||||
if (!window) return false;
|
||||
{
|
||||
std::lock_guard lock(render_mutex_);
|
||||
if (pending_window_) ANativeWindow_release(pending_window_);
|
||||
pending_window_ = window;
|
||||
surface_changed_ = true;
|
||||
}
|
||||
render_cv_.notify_one();
|
||||
return true;
|
||||
}
|
||||
|
||||
void detach() {
|
||||
{
|
||||
std::lock_guard lock(render_mutex_);
|
||||
if (pending_window_) {
|
||||
ANativeWindow_release(pending_window_);
|
||||
pending_window_ = nullptr;
|
||||
}
|
||||
surface_changed_ = true;
|
||||
}
|
||||
render_cv_.notify_one();
|
||||
}
|
||||
|
||||
bool load(uint64_t handle, double start, const std::string &audio,
|
||||
const std::string &subtitle, bool subtitles) {
|
||||
if (!mpv_) return false;
|
||||
// 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 —
|
||||
// waiting for a surface here is what made background playback fail after
|
||||
// a five-second stall.
|
||||
if (!audio.empty()) set_string("alang", audio);
|
||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||
set_string("sid", subtitles ? "auto" : "no");
|
||||
pending_start_.store(std::max(0.0, start));
|
||||
// Retire the outgoing file's position, duration and end flag *before*
|
||||
// the new one loads. mpv only republishes them once it has demuxed
|
||||
// enough of the stream, and until then every reader would otherwise see
|
||||
// the last episode's clock under the new episode's name.
|
||||
{
|
||||
std::lock_guard lock(state_mutex_);
|
||||
const double volume = state_.volume;
|
||||
const bool muted = state_.muted;
|
||||
// `pause` is a property of the core, not of the file: mpv carries it
|
||||
// across `loadfile` unchanged and therefore never re-publishes it.
|
||||
// Resetting it to the struct's default would leave every reader
|
||||
// believing a playing file is paused for as long as it is open, and
|
||||
// every "play" they then send is a no-op against a core that never
|
||||
// paused — which is a play/pause button that does nothing.
|
||||
const bool paused = state_.paused;
|
||||
const int64_t generation = state_.generation + 1;
|
||||
state_ = PlaybackState{};
|
||||
state_.volume = volume;
|
||||
state_.muted = muted;
|
||||
state_.paused = paused;
|
||||
state_.generation = generation;
|
||||
state_.loading = true;
|
||||
error_.clear();
|
||||
}
|
||||
const std::string url = "pstr://" + std::to_string(handle);
|
||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||
if (mpv_command(mpv_, command) < 0) {
|
||||
// Nothing was issued, so no START_FILE will arrive to clear this.
|
||||
std::lock_guard lock(state_mutex_);
|
||||
state_.loading = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Written through rather than left to the observer: the transport is drawn
|
||||
/// from this state, and a tap that only shows its effect on mpv's next
|
||||
/// property event reads as a button that missed.
|
||||
void pause(bool value) {
|
||||
set_flag("pause", value);
|
||||
std::lock_guard lock(state_mutex_);
|
||||
state_.paused = value;
|
||||
}
|
||||
void seek(double seconds) { set_double("time-pos", std::max(0.0, seconds)); }
|
||||
void volume(double value) { set_double("volume", std::clamp(value, 0.0, 100.0)); }
|
||||
void speed(double value) { set_double("speed", std::clamp(value, 0.25, 4.0)); }
|
||||
void mute(bool value) { set_flag("mute", value); }
|
||||
void select_track(bool audio, int64_t id) {
|
||||
const char *property = audio ? "aid" : "sid";
|
||||
if (id <= 0) set_string(property, "no"); else mpv_set_property(mpv_, property, MPV_FORMAT_INT64, &id);
|
||||
}
|
||||
void stop() {
|
||||
if (!mpv_) return;
|
||||
const char *command[] = {"stop", nullptr};
|
||||
mpv_command(mpv_, command);
|
||||
}
|
||||
|
||||
PlaybackState state() const {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
return state_;
|
||||
}
|
||||
|
||||
/// The pending error, if any, consumed by reading it.
|
||||
std::string take_error() {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
return std::exchange(error_, std::string());
|
||||
}
|
||||
|
||||
std::string tracks_json() const {
|
||||
if (!mpv_) return "[]";
|
||||
int64_t count = 0;
|
||||
if (mpv_get_property(mpv_, "track-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
|
||||
std::string json = "[";
|
||||
for (int64_t i = 0; i < count; ++i) {
|
||||
const std::string base = "track-list/" + std::to_string(i) + "/";
|
||||
int64_t id = 0;
|
||||
char *type_raw = nullptr;
|
||||
char *lang_raw = nullptr;
|
||||
char *title_raw = nullptr;
|
||||
int selected = 0;
|
||||
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
||||
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||
MpvString type(type_raw);
|
||||
mpv_get_property(mpv_, (base + "lang").c_str(), MPV_FORMAT_STRING, &lang_raw);
|
||||
MpvString lang(lang_raw);
|
||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||
MpvString title(title_raw);
|
||||
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
||||
if (i) json += ',';
|
||||
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
||||
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}";
|
||||
}
|
||||
return json + ']';
|
||||
}
|
||||
|
||||
/// The file's chapters, read one sub-property at a time like the tracks.
|
||||
/// What they *mean* is decided in Rust (`pstr_core::chapters`), so the
|
||||
/// desktop and this client offer the same skips.
|
||||
std::string chapters_json() const {
|
||||
if (!mpv_) return "[]";
|
||||
int64_t count = 0;
|
||||
if (mpv_get_property(mpv_, "chapter-list/count", MPV_FORMAT_INT64, &count) < 0) return "[]";
|
||||
std::string json = "[";
|
||||
for (int64_t i = 0; i < count; ++i) {
|
||||
const std::string base = "chapter-list/" + std::to_string(i) + "/";
|
||||
char *title_raw = nullptr;
|
||||
double start = 0.0;
|
||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||
MpvString title(title_raw);
|
||||
mpv_get_property(mpv_, (base + "time").c_str(), MPV_FORMAT_DOUBLE, &start);
|
||||
if (i) json += ',';
|
||||
json += "{\"index\":" + std::to_string(i) + ",\"title\":\"" + escape(title.get()) +
|
||||
"\",\"start\":" + std::to_string(start) + '}';
|
||||
}
|
||||
return json + ']';
|
||||
}
|
||||
|
||||
private:
|
||||
struct MpvFree {
|
||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||
};
|
||||
using MpvString = std::unique_ptr<char, MpvFree>;
|
||||
|
||||
/// JSON-escape `text`, and re-encode it as Modified UTF-8.
|
||||
///
|
||||
/// The result of this ends up in `NewStringUTF`, which does not take UTF-8:
|
||||
/// it takes Modified UTF-8, where a character outside the basic plane is a
|
||||
/// surrogate *pair* of three-byte sequences rather than one four-byte
|
||||
/// sequence. Handing it real UTF-8 renders an emoji in a track title as
|
||||
/// mojibake, and aborts the process under CheckJNI.
|
||||
///
|
||||
/// Anything that is not well-formed UTF-8 is dropped rather than passed
|
||||
/// through, for the same reason: a truncated sequence is what CheckJNI
|
||||
/// rejects, and a track title is not worth a crash.
|
||||
static std::string escape(const char *text) {
|
||||
std::string out;
|
||||
if (!text) return out;
|
||||
const std::string_view input(text);
|
||||
for (size_t i = 0; i < input.size();) {
|
||||
const auto lead = static_cast<unsigned char>(input[i]);
|
||||
size_t length = 0;
|
||||
uint32_t code = 0;
|
||||
if (lead < 0x80) {
|
||||
length = 1;
|
||||
code = lead;
|
||||
} else if ((lead & 0xE0) == 0xC0) {
|
||||
length = 2;
|
||||
code = lead & 0x1Fu;
|
||||
} else if ((lead & 0xF0) == 0xE0) {
|
||||
length = 3;
|
||||
code = lead & 0x0Fu;
|
||||
} else if ((lead & 0xF8) == 0xF0) {
|
||||
length = 4;
|
||||
code = lead & 0x07u;
|
||||
} else {
|
||||
++i; // A stray continuation or invalid lead byte.
|
||||
continue;
|
||||
}
|
||||
if (i + length > input.size()) break;
|
||||
bool valid = true;
|
||||
for (size_t k = 1; k < length; ++k) {
|
||||
const auto next = static_cast<unsigned char>(input[i + k]);
|
||||
if ((next & 0xC0) != 0x80) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
code = (code << 6) | (next & 0x3Fu);
|
||||
}
|
||||
if (!valid) {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
i += length;
|
||||
|
||||
if (code < 0x20) continue; // Control characters, including NUL.
|
||||
if (code == '"' || code == '\\') {
|
||||
out += '\\';
|
||||
out += static_cast<char>(code);
|
||||
continue;
|
||||
}
|
||||
if (code < 0x80) {
|
||||
out += static_cast<char>(code);
|
||||
} else if (code < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (code >> 6));
|
||||
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
append_three(out, code);
|
||||
} else {
|
||||
// The surrogate pair Modified UTF-8 wants, each half written as
|
||||
// its own three-byte sequence.
|
||||
const uint32_t rest = code - 0x10000;
|
||||
append_three(out, 0xD800 + (rest >> 10));
|
||||
append_three(out, 0xDC00 + (rest & 0x3FF));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void append_three(std::string &out, uint32_t code) {
|
||||
out += static_cast<char>(0xE0 | (code >> 12));
|
||||
out += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||
}
|
||||
|
||||
void option(const char *name, const char *value) { mpv_set_option_string(mpv_, name, value); }
|
||||
void observe(const char *name, uint64_t id, mpv_format format) { mpv_observe_property(mpv_, id, name, format); }
|
||||
void set_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
|
||||
void set_double(const char *name, double value) { if (mpv_) mpv_set_property(mpv_, name, MPV_FORMAT_DOUBLE, &value); }
|
||||
void set_flag(const char *name, bool value) { int flag = value; if (mpv_) mpv_set_property(mpv_, name, MPV_FORMAT_FLAG, &flag); }
|
||||
|
||||
void event_loop() {
|
||||
while (running_.load()) {
|
||||
mpv_event *event = mpv_wait_event(mpv_, 0.25);
|
||||
if (event->event_id == MPV_EVENT_NONE) continue;
|
||||
if (event->event_id == MPV_EVENT_SHUTDOWN) break;
|
||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||
const double start = pending_start_.exchange(0.0);
|
||||
if (start > 0.0) set_double("time-pos", start);
|
||||
}
|
||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||
std::lock_guard lock(state_mutex_);
|
||||
// The end of the file `loadfile` is replacing, not of the one it
|
||||
// is loading. Reporting it would hand every reader an episode
|
||||
// that ended before it started.
|
||||
if (state_.loading) continue;
|
||||
state_.ended = true;
|
||||
state_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
|
||||
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
state_.loading = false;
|
||||
state_.ended = false;
|
||||
state_.end_reason = EndReason::None;
|
||||
} else if (event->event_id == MPV_EVENT_SEEK) {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
state_.seeking = true;
|
||||
} else if (event->event_id == MPV_EVENT_PLAYBACK_RESTART) {
|
||||
// The seek is *visibly* done here, not when it was issued.
|
||||
std::lock_guard lock(state_mutex_);
|
||||
state_.seeking = false;
|
||||
} else if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
|
||||
record_log(*static_cast<mpv_event_log_message *>(event->data));
|
||||
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
|
||||
update_property(*static_cast<mpv_event_property *>(event->data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the first error of the open file, not the last.
|
||||
///
|
||||
/// A failure cascades — one unreadable header produces a dozen follow-on
|
||||
/// complaints — and the first line is the one that names the cause. Cleared
|
||||
/// by [take_error], which is how the UI consumes it.
|
||||
///
|
||||
/// Only mpv's own errors. Everything libavcodec and libavformat complain
|
||||
/// about arrives under an `ffmpeg/…` prefix, and mpv is the thing that
|
||||
/// decides whether any of it mattered: a decoder that refuses to
|
||||
/// initialise is an error there and a fallback here, so surfacing those
|
||||
/// puts a dialog over a file that goes on to play perfectly. When one of
|
||||
/// them really is fatal mpv says so itself, under a prefix of its own.
|
||||
void record_log(const mpv_event_log_message &message) {
|
||||
if (!message.text) return;
|
||||
if (message.prefix && !std::strncmp(message.prefix, "ffmpeg", 6)) return;
|
||||
std::lock_guard lock(state_mutex_);
|
||||
if (!error_.empty()) return;
|
||||
error_.assign(message.text);
|
||||
while (!error_.empty() && (error_.back() == '\n' || error_.back() == '\r')) error_.pop_back();
|
||||
}
|
||||
|
||||
void update_property(const mpv_event_property &property) {
|
||||
if (!property.data) return;
|
||||
std::lock_guard lock(state_mutex_);
|
||||
// `volume`, `mute` and `pause` belong to the core and carry across a
|
||||
// load; the rest describe the open file, and a reading of them that
|
||||
// arrives mid-load is the outgoing episode's clock under the incoming
|
||||
// episode's name.
|
||||
if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
|
||||
else if (!std::strcmp(property.name, "pause")) state_.paused = *static_cast<int *>(property.data);
|
||||
else if (!std::strcmp(property.name, "mute")) state_.muted = *static_cast<int *>(property.data);
|
||||
else if (state_.loading) return;
|
||||
else if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
|
||||
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
|
||||
else if (!std::strcmp(property.name, "video-params/dw")) state_.video_width = 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, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||
}
|
||||
|
||||
static void render_update(void *opaque) noexcept {
|
||||
c_boundary([=] {
|
||||
auto *self = static_cast<Player *>(opaque);
|
||||
if (!self) return;
|
||||
self->frame_ready_.store(true);
|
||||
self->render_cv_.notify_one();
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns EGL and the mpv render context for the life of the player.
|
||||
///
|
||||
/// Both are set up here rather than on the first surface attach, and neither
|
||||
/// depends on there being a window: a pbuffer is enough to make a context
|
||||
/// current, and `vo_libmpv` only needs *some* consumer. That is what makes
|
||||
/// audio playback with no surface — the screen off, the app backgrounded —
|
||||
/// structurally possible; before this, `load` waited five seconds for a
|
||||
/// render context that only a visible SurfaceView could ever create, and
|
||||
/// then failed.
|
||||
void render_loop() {
|
||||
EGLDisplay display = EGL_NO_DISPLAY;
|
||||
EGLContext context = EGL_NO_CONTEXT;
|
||||
EGLSurface surface = EGL_NO_SURFACE;
|
||||
EGLSurface pbuffer = EGL_NO_SURFACE;
|
||||
EGLConfig config = nullptr;
|
||||
ANativeWindow *window = nullptr;
|
||||
if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
|
||||
while (running_.load()) {
|
||||
std::unique_lock lock(render_mutex_);
|
||||
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
||||
if (!running_.load()) break;
|
||||
if (surface_changed_) {
|
||||
surface_changed_ = false;
|
||||
if (surface != EGL_NO_SURFACE) { eglMakeCurrent(display, pbuffer, pbuffer, context); eglDestroySurface(display, surface); surface = EGL_NO_SURFACE; }
|
||||
if (window) ANativeWindow_release(window);
|
||||
window = pending_window_;
|
||||
pending_window_ = nullptr;
|
||||
if (window && display == EGL_NO_DISPLAY) {
|
||||
// EGL failed at startup and playback is audio-only. Keep the
|
||||
// window rather than the surface: nothing here can retry.
|
||||
ANativeWindow_release(window);
|
||||
window = nullptr;
|
||||
}
|
||||
if (window) {
|
||||
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
||||
eglMakeCurrent(display, surface, surface, context);
|
||||
}
|
||||
}
|
||||
const bool draw = frame_ready_.exchange(false);
|
||||
lock.unlock();
|
||||
// With no window there is still a frame to consume. `vo_libmpv`
|
||||
// expects its host to keep draining, and a video thread with no
|
||||
// consumer stalls — which is why video came back out of sync with
|
||||
// audio after the screen had been off.
|
||||
if (draw && surface == EGL_NO_SURFACE && render_) {
|
||||
eglMakeCurrent(display, pbuffer, pbuffer, context);
|
||||
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
||||
int skip = 1;
|
||||
mpv_render_param params[] = {{MPV_RENDER_PARAM_SKIP_RENDERING, &skip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
|
||||
mpv_render_context_render(render_, params);
|
||||
}
|
||||
}
|
||||
if (draw && surface != EGL_NO_SURFACE && render_) {
|
||||
eglMakeCurrent(display, surface, surface, context);
|
||||
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
||||
EGLint width = 0, height = 0;
|
||||
eglQuerySurface(display, surface, EGL_WIDTH, &width);
|
||||
eglQuerySurface(display, surface, EGL_HEIGHT, &height);
|
||||
mpv_opengl_fbo fbo{0, width, height, 0};
|
||||
int flip = 1;
|
||||
mpv_render_param params[] = {{MPV_RENDER_PARAM_OPENGL_FBO, &fbo}, {MPV_RENDER_PARAM_FLIP_Y, &flip}, {MPV_RENDER_PARAM_INVALID, nullptr}};
|
||||
mpv_render_context_render(render_, params);
|
||||
eglSwapBuffers(display, surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (render_) {
|
||||
eglMakeCurrent(display, surface != EGL_NO_SURFACE ? surface : pbuffer, surface != EGL_NO_SURFACE ? surface : pbuffer, context);
|
||||
mpv_render_context_set_update_callback(render_, nullptr, nullptr);
|
||||
mpv_render_context_free(render_);
|
||||
render_ = nullptr;
|
||||
}
|
||||
if (surface != EGL_NO_SURFACE) eglDestroySurface(display, surface);
|
||||
if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
|
||||
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||
if (display != EGL_NO_DISPLAY) eglTerminate(display);
|
||||
if (window) ANativeWindow_release(window);
|
||||
std::lock_guard lock(render_mutex_);
|
||||
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
|
||||
}
|
||||
|
||||
/// All of EGL, or none of it.
|
||||
///
|
||||
/// Leaving a display initialised behind a failed context is what made a
|
||||
/// partial failure permanent: the retry guard is "is there a display", so a
|
||||
/// half-built state reads as success forever and every later attach uses an
|
||||
/// uninitialised config and `EGL_NO_CONTEXT`. Unwinding here keeps the guard
|
||||
/// honest, and `eglGetError` says which step failed instead of leaving a
|
||||
/// black screen with nothing in the log.
|
||||
bool initialize_egl(EGLDisplay &display, EGLContext &context, EGLConfig &config, EGLSurface &pbuffer) {
|
||||
const auto fail = [&](const char *step) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "pstr-mpv", "%s failed: 0x%04x", step, eglGetError());
|
||||
if (pbuffer != EGL_NO_SURFACE) eglDestroySurface(display, pbuffer);
|
||||
if (context != EGL_NO_CONTEXT) eglDestroyContext(display, context);
|
||||
if (display != EGL_NO_DISPLAY) eglTerminate(display);
|
||||
display = EGL_NO_DISPLAY;
|
||||
context = EGL_NO_CONTEXT;
|
||||
pbuffer = EGL_NO_SURFACE;
|
||||
config = nullptr;
|
||||
return false;
|
||||
};
|
||||
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if (display == EGL_NO_DISPLAY) return fail("eglGetDisplay");
|
||||
if (!eglInitialize(display, nullptr, nullptr)) return fail("eglInitialize");
|
||||
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_SURFACE_TYPE, EGL_WINDOW_BIT | EGL_PBUFFER_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
|
||||
EGLint count = 0;
|
||||
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return fail("eglChooseConfig");
|
||||
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
||||
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
||||
if (context == EGL_NO_CONTEXT) return fail("eglCreateContext");
|
||||
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
||||
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
||||
if (pbuffer == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface");
|
||||
if (!eglMakeCurrent(display, pbuffer, pbuffer, context)) return fail("eglMakeCurrent");
|
||||
return true;
|
||||
}
|
||||
|
||||
void initialize_renderer() {
|
||||
if (render_ || !mpv_) return;
|
||||
mpv_opengl_init_params gl{resolve_gl, nullptr};
|
||||
const char *api = MPV_RENDER_API_TYPE_OPENGL;
|
||||
mpv_render_param params[] = {{MPV_RENDER_PARAM_API_TYPE, const_cast<char *>(api)}, {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl}, {MPV_RENDER_PARAM_INVALID, nullptr}};
|
||||
if (mpv_render_context_create(&render_, mpv_, params) >= 0) {
|
||||
mpv_render_context_set_update_callback(render_, render_update, this);
|
||||
render_cv_.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
mpv_handle *mpv_ = nullptr;
|
||||
mpv_render_context *render_ = nullptr;
|
||||
std::atomic<bool> running_{true};
|
||||
std::thread events_;
|
||||
std::thread renderer_;
|
||||
mutable std::mutex state_mutex_;
|
||||
PlaybackState state_;
|
||||
/// The first error mpv logged about the open file, until it is consumed.
|
||||
std::string error_;
|
||||
std::mutex render_mutex_;
|
||||
std::condition_variable render_cv_;
|
||||
ANativeWindow *pending_window_ = nullptr;
|
||||
bool surface_changed_ = false;
|
||||
std::atomic<bool> frame_ready_{false};
|
||||
std::atomic<double> pending_start_{0.0};
|
||||
};
|
||||
|
||||
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
||||
jstring string(JNIEnv *env, const std::string &value) { return env->NewStringUTF(value.c_str()); }
|
||||
|
||||
class UtfChars {
|
||||
public:
|
||||
UtfChars(JNIEnv *env, jstring value) noexcept
|
||||
: env_(env), value_(value), characters_(env->GetStringUTFChars(value, nullptr)) {}
|
||||
~UtfChars() noexcept {
|
||||
if (characters_) env_->ReleaseStringUTFChars(value_, characters_);
|
||||
}
|
||||
UtfChars(const UtfChars &) = delete;
|
||||
UtfChars &operator=(const UtfChars &) = delete;
|
||||
const char *get() const noexcept { return characters_; }
|
||||
|
||||
private:
|
||||
JNIEnv *env_;
|
||||
jstring value_;
|
||||
const char *characters_;
|
||||
};
|
||||
|
||||
bool utf8(JNIEnv *env, jstring value, std::string &result) {
|
||||
if (!value) return true;
|
||||
UtfChars characters(env, value);
|
||||
if (!characters.get()) return false; // An exception, normally OOM, is pending.
|
||||
result.assign(characters.get());
|
||||
return !env->ExceptionCheck();
|
||||
}
|
||||
|
||||
void reject_null_surface(JNIEnv *env) noexcept {
|
||||
c_boundary([=] {
|
||||
jclass type = env->FindClass("java/lang/IllegalArgumentException");
|
||||
if (!type) return;
|
||||
env->ThrowNew(type, "surface must not be null");
|
||||
// FindClass hands back a local reference, and this is called from a
|
||||
// long-lived attached thread where the frame is not popped for us.
|
||||
env->DeleteLocalRef(type);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/// FFmpeg's MediaCodec decoders call into Android's Java API, and they can only
|
||||
/// reach it through a JavaVM handed to them explicitly — there is no way for
|
||||
/// them to discover one. Without this, every hardware decoder fails to open
|
||||
/// with "hevc_mediacodec: No Java virtual machine has been registered" and
|
||||
/// playback falls back to software decoding, which on a phone means a hot
|
||||
/// device and dropped frames on anything above 1080p.
|
||||
///
|
||||
/// mpv-android's own JNI layer does this in its `create` entry point; this
|
||||
/// library replaces that layer, so the registration has to happen here. The
|
||||
/// loader calls this once per process before any native method, which is
|
||||
/// earlier than any decoder can run.
|
||||
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
|
||||
av_jni_set_java_vm(vm, nullptr);
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept {
|
||||
return c_boundary<jlong>(0, [] {
|
||||
auto player = std::make_unique<Player>();
|
||||
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDestroy(JNIEnv *, jobject, jlong handle) noexcept {
|
||||
c_boundary([=] { delete from(handle); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAttachSurface(JNIEnv *env, jobject, jlong handle, jobject surface) noexcept {
|
||||
c_boundary([=] {
|
||||
if (!surface) { reject_null_surface(env); return; }
|
||||
if (auto *p = from(handle)) p->attach(env, surface);
|
||||
});
|
||||
}
|
||||
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(); });
|
||||
}
|
||||
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 {
|
||||
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
||||
std::string audio_text;
|
||||
std::string subtitle_text;
|
||||
if (!utf8(env, audio, audio_text) || !utf8(env, subtitle, subtitle_text)) {
|
||||
return jboolean{JNI_FALSE};
|
||||
}
|
||||
auto *p = from(handle);
|
||||
return static_cast<jboolean>(
|
||||
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
||||
subtitle_text, subtitles == JNI_TRUE)
|
||||
? JNI_TRUE
|
||||
: JNI_FALSE);
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSeek(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->seek(value); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->volume(value); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSpeed(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->speed(value); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeMute(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeSelectTrack(JNIEnv *, jobject, jlong h, jboolean audio, jlong id) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->select_track(audio == JNI_TRUE, id); });
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeStop(JNIEnv *, jobject, jlong h) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->stop(); });
|
||||
}
|
||||
extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeState(JNIEnv *env, jobject, jlong h) noexcept {
|
||||
return c_boundary<jdoubleArray>(nullptr, [=] {
|
||||
const auto state = from(h) ? from(h)->state() : PlaybackState{};
|
||||
const jdouble values[] = {state.position, state.duration, state.volume,
|
||||
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
||||
state.ended ? 1.0 : 0.0,
|
||||
static_cast<jdouble>(state.generation),
|
||||
state.video_width, state.video_height,
|
||||
static_cast<jdouble>(state.end_reason),
|
||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||
state.seeking ? 1.0 : 0.0};
|
||||
constexpr jsize kFields = 13;
|
||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTakeError(JNIEnv *env, jobject, jlong h) noexcept {
|
||||
return c_boundary<jstring>(nullptr, [=]() -> jstring {
|
||||
auto *player = from(h);
|
||||
if (!player) return nullptr;
|
||||
const std::string error = player->take_error();
|
||||
return error.empty() ? nullptr : string(env, error);
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
|
||||
return c_boundary<jstring>(nullptr, [=] {
|
||||
return string(env, from(h) ? from(h)->tracks_json() : "[]");
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeChapters(JNIEnv *env, jobject, jlong h) noexcept {
|
||||
return c_boundary<jstring>(nullptr, [=] {
|
||||
return string(env, from(h) ? from(h)->chapters_json() : "[]");
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_pstrAndroidStreamRelease(JNIEnv *, jobject, jlong handle) noexcept {
|
||||
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
|
||||
int64_t pstr_android_stream_size(uint64_t handle);
|
||||
/// Interrupts an in-flight `pstr_android_stream_read`. A cancel with no reader
|
||||
/// waiting is dropped, so this is safe to call speculatively.
|
||||
void pstr_android_stream_cancel(uint64_t handle);
|
||||
void pstr_android_stream_release(uint64_t handle);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,277 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
private val inPictureInPicture = mutableStateOf(false)
|
||||
/**
|
||||
* Whether a bind is outstanding, which is what has to be unbound.
|
||||
*
|
||||
* Set when `bindService` is called rather than when the connection arrives:
|
||||
* an activity destroyed while the bind is still pending owes the system an
|
||||
* `unbindService` just the same, and `onServiceConnected` may never run.
|
||||
*/
|
||||
private var bound = false
|
||||
private val playbackConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
playerHost.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport behind the buttons Android draws over the PiP window.
|
||||
*
|
||||
* A broadcast rather than a service intent: PiP is one of the states in
|
||||
* which a background service start is refused, and the receiver is
|
||||
* registered for as long as the activity that owns the window exists.
|
||||
*/
|
||||
private val pictureInPictureControls = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val host = playerHost.value ?: return
|
||||
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
||||
CONTROL_PLAY -> host.setPaused(false)
|
||||
CONTROL_PAUSE -> host.setPaused(true)
|
||||
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
||||
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationPermission = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before anything draws. The palette decides the window's own
|
||||
// background and which way round the system bars' icons go, and both
|
||||
// are wrong for a frame if they are set from a composition instead.
|
||||
// Infallible by construction — an unreadable theme file resolves to the
|
||||
// shipped default rather than throwing.
|
||||
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
||||
enableEdgeToEdge()
|
||||
// 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
|
||||
// frame the page behind them turns light.
|
||||
lifecycleScope.launch {
|
||||
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
|
||||
}
|
||||
bound = bindService(
|
||||
Intent(this, PlaybackService::class.java),
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
pictureInPictureControls,
|
||||
IntentFilter(ACTION_PIP_CONTROL),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
||||
// building the params inside onUserLeaveHint is too late for the swipe
|
||||
// home that gesture navigation sends, which never calls it. Re-register
|
||||
// whenever the picture's shape or the transport's does.
|
||||
lifecycleScope.launch {
|
||||
snapshotFlow { playerHost.value }
|
||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
||||
.map { state -> PictureInPictureShape.of(state) }
|
||||
.distinctUntilChanged()
|
||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
||||
}
|
||||
setContent {
|
||||
ProtonStreamTheme {
|
||||
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of the window Compose does not reach: the background behind the
|
||||
* whole activity, and the polarity of the system bars' icons.
|
||||
*
|
||||
* The background matters for the frames before and between compositions —
|
||||
* the launch frame, and a rotation. It used to be a hex literal in
|
||||
* `themes.xml`, which meant every cold start showed a colour from one
|
||||
* flavour regardless of which one was chosen.
|
||||
*
|
||||
* The icons matter because the app ships a light flavour. Pinned dark, as
|
||||
* they were, Latte drew white status-bar icons onto a near-white page.
|
||||
*/
|
||||
private fun dressWindow(palette: PaletteRecord) {
|
||||
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = palette.light
|
||||
isAppearanceLightNavigationBars = palette.light
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// The backstop for launchers that still send this instead of arming
|
||||
// auto-enter. Entering twice is not an error; not entering at all is.
|
||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
||||
runCatching {
|
||||
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
// The window is now a thumbnail with its own controls. Anything the
|
||||
// player would draw over it — the transport, the title bar, the skip
|
||||
// offer — is unreadable at that size and covers the picture.
|
||||
inPictureInPicture.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!isInPictureInPictureMode &&
|
||||
!SettingsStore(this).backgroundAudio
|
||||
) {
|
||||
playerHost.value?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
||||
private data class PictureInPictureShape(
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
||||
active = state != null && state.duration > 0.0 && !state.ended,
|
||||
paused = state?.paused != false,
|
||||
width = state?.videoWidth?.toInt() ?: 0,
|
||||
height = state?.videoHeight?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
||||
PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio(width, height))
|
||||
.setAutoEnterEnabled(active)
|
||||
.setSeamlessResizeEnabled(false)
|
||||
// The player owns the whole window, so the picture's rectangle is
|
||||
// the window's. Without it the transition into PiP cross-fades from
|
||||
// a screenshot instead of shrinking the video that is already there.
|
||||
.setSourceRectHint(windowBounds())
|
||||
.setActions(
|
||||
listOf(
|
||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
||||
|
||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
||||
RemoteAction(
|
||||
Icon.createWithResource(this, icon),
|
||||
label,
|
||||
label,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
||||
const val EXTRA_CONTROL = "control"
|
||||
const val CONTROL_PLAY = "play"
|
||||
const val CONTROL_PAUSE = "pause"
|
||||
const val CONTROL_NEXT = "next"
|
||||
const val CONTROL_PREVIOUS = "previous"
|
||||
|
||||
/**
|
||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
||||
* and refuses the request outright rather than clamping it — so a
|
||||
* 2.40:1 scope release would take PiP away entirely.
|
||||
*/
|
||||
fun aspectRatio(width: Int, height: Int): Rational {
|
||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
||||
val ratio = width.toDouble() / height
|
||||
return when {
|
||||
ratio > MAX_RATIO -> Rational(239, 100)
|
||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
||||
else -> Rational(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
const val MAX_RATIO = 2.39
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.app.Application
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
|
||||
class ProtonStreamApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NativeRuntime.initialize(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
object DownloadCoordinator {
|
||||
/**
|
||||
* Where bulk queueing runs.
|
||||
*
|
||||
* Callers are click handlers, and "Download show" on a long series is a
|
||||
* WorkManager enqueue per episode — each of which writes to its own
|
||||
* database. On the main thread that is an ANR waiting for a long enough
|
||||
* series.
|
||||
*/
|
||||
private val queueing = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
fun enqueue(context: Context, episode: EpisodeRecord) = enqueue(context, listOf(episode))
|
||||
|
||||
fun resume(context: Context, download: RetainedDownload) {
|
||||
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
||||
}
|
||||
|
||||
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
||||
val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
|
||||
DownloadStateStore(context).put(queued)
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
workName(download.shareId, download.linkId),
|
||||
policy,
|
||||
request(queued, SettingsStore(context).wifiOnly),
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline).toList()
|
||||
if (wanted.isEmpty()) return
|
||||
val application = context.applicationContext
|
||||
queueing.launch {
|
||||
val wifiOnly = SettingsStore(application).wifiOnly
|
||||
val store = DownloadStateStore(application)
|
||||
val manager = WorkManager.getInstance(application)
|
||||
// Space is checked once for the whole request, against its total.
|
||||
// Queueing a season that cannot fit and discovering it episode by
|
||||
// episode at ENOSPC is how the app fills its own database's disk.
|
||||
val room = roomFor(application, wanted.sumOf { it.size?.toLong() ?: 0L })
|
||||
val downloads = wanted.map { episode ->
|
||||
RetainedDownload(
|
||||
episode.shareId,
|
||||
episode.volumeId,
|
||||
episode.linkId,
|
||||
episode.label,
|
||||
total = episode.size?.toLong() ?: 0L,
|
||||
status = if (room) RetainedDownload.STATUS_QUEUED else RetainedDownload.STATUS_FAILED,
|
||||
error = if (room) null else "Not enough free space",
|
||||
)
|
||||
}
|
||||
store.putAll(downloads)
|
||||
if (!room) return@launch
|
||||
downloads.forEach { download ->
|
||||
manager.enqueueUniqueWork(
|
||||
workName(download.shareId, download.linkId),
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request(download, wifiOnly),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [bytes] plus working room will fit where offline files are kept.
|
||||
*
|
||||
* `setRequiresStorageNotLow` only holds work back at Android's own low-storage
|
||||
* threshold, which a single episode can cross on its own. An unknown size —
|
||||
* zero — is allowed through: refusing a download because the catalog has no
|
||||
* size for it would be worse than the ENOSPC it is guarding against.
|
||||
*/
|
||||
private fun roomFor(context: Context, bytes: Long): Boolean {
|
||||
if (bytes <= 0) return true
|
||||
val available = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrNull() ?: return true
|
||||
return available - bytes >= STORAGE_HEADROOM
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply the network constraint to work that is already queued.
|
||||
*
|
||||
* Constraints are baked in at enqueue time, so turning "Wi-Fi only" on after
|
||||
* queueing a season did nothing to that season — it downloaded over cellular
|
||||
* on the way out of the house, which is the one moment the setting exists
|
||||
* for. Running work is left alone: WorkManager stops it on its own when the
|
||||
* new constraint stops being met.
|
||||
*/
|
||||
fun applyNetworkPolicy(context: Context) {
|
||||
val application = context.applicationContext
|
||||
queueing.launch {
|
||||
val wifiOnly = SettingsStore(application).wifiOnly
|
||||
val manager = WorkManager.getInstance(application)
|
||||
DownloadStateStore(application).records()
|
||||
.filter { it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING }
|
||||
.forEach { download ->
|
||||
manager.enqueueUniqueWork(
|
||||
workName(download.shareId, download.linkId),
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request(download, wifiOnly),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause(context: Context, download: RetainedDownload) =
|
||||
setStatus(context, download, RetainedDownload.STATUS_PAUSED)
|
||||
|
||||
fun cancel(context: Context, download: RetainedDownload) =
|
||||
setStatus(context, download, RetainedDownload.STATUS_CANCELLED)
|
||||
|
||||
/**
|
||||
* The status write goes before the work is cancelled, and through `update`
|
||||
* rather than `put`: the running worker is writing progress into the same
|
||||
* record, and a whole-record `put` from a screen's stale copy would both
|
||||
* lose that progress and race the worker's own read-modify-write.
|
||||
*/
|
||||
private fun setStatus(context: Context, download: RetainedDownload, status: String) {
|
||||
DownloadStateStore(context).update(download.shareId, download.linkId) { stored ->
|
||||
(stored ?: download).copy(status = status, error = null)
|
||||
}
|
||||
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
||||
}
|
||||
|
||||
private fun request(download: RetainedDownload, wifiOnly: Boolean): OneTimeWorkRequest =
|
||||
OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(requiredNetworkType(wifiOnly))
|
||||
// A download that fills the disk takes the app's own
|
||||
// database down with it; the `.part` file makes waiting free.
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build(),
|
||||
)
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
OfflineDownloadWorker.KEY_SHARE_ID to download.shareId,
|
||||
OfflineDownloadWorker.KEY_VOLUME_ID to download.volumeId,
|
||||
OfflineDownloadWorker.KEY_LINK_ID to download.linkId,
|
||||
OfflineDownloadWorker.KEY_LABEL to download.label,
|
||||
),
|
||||
)
|
||||
.addTag(TAG)
|
||||
.addTag("$EPISODE_TAG${download.label}")
|
||||
.addTag(shareTag(download.shareId))
|
||||
.build()
|
||||
|
||||
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
|
||||
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
||||
|
||||
const val TAG = "offline-download"
|
||||
const val EPISODE_TAG = "offline-episode:"
|
||||
private const val SHARE_TAG = "offline-share:"
|
||||
|
||||
/** Free space to leave behind a download, for the catalog and the caches. */
|
||||
private const val STORAGE_HEADROOM = 256L * 1024 * 1024
|
||||
}
|
||||
|
||||
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
|
||||
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
|
||||
@@ -0,0 +1,129 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Durable user-facing state retained after WorkManager prunes finished work. */
|
||||
data class RetainedDownload(
|
||||
val shareId: String,
|
||||
val volumeId: String,
|
||||
val linkId: String,
|
||||
val label: String,
|
||||
val downloaded: Long = 0,
|
||||
val total: Long = 0,
|
||||
val status: String = STATUS_QUEUED,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val key: String get() = "$shareId\u001f$linkId"
|
||||
|
||||
companion object {
|
||||
const val STATUS_QUEUED = "queued"
|
||||
const val STATUS_RUNNING = "running"
|
||||
const val STATUS_PAUSED = "paused"
|
||||
const val STATUS_FAILED = "failed"
|
||||
const val STATUS_CANCELLED = "cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadStateStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun records(): List<RetainedDownload> = preferences.all.values.mapNotNull { encoded ->
|
||||
runCatching { decode(encoded as String) }.getOrNull()
|
||||
}.sortedBy { it.label.lowercase() }
|
||||
|
||||
fun get(shareId: String, linkId: String): RetainedDownload? =
|
||||
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
||||
|
||||
fun put(record: RetainedDownload) = synchronized(WRITES) {
|
||||
edit { putString(record.key, encode(record)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-modify-write one record, excluding every other writer.
|
||||
*
|
||||
* The worker's progress update reads the stored record to decide whether the
|
||||
* viewer has paused, then writes it back. `pause()` is another writer, and
|
||||
* without this a `put` landing between those two halves puts `RUNNING` back
|
||||
* over `PAUSED` — after which the worker's own cancellation handler sees a
|
||||
* status that is not `PAUSED` and records `CANCELLED`. The pause is simply
|
||||
* lost, which is the visible bug.
|
||||
*
|
||||
* Returning null from [change] leaves the record alone.
|
||||
*/
|
||||
fun update(shareId: String, linkId: String, change: (RetainedDownload?) -> RetainedDownload?) =
|
||||
synchronized(WRITES) {
|
||||
change(get(shareId, linkId))?.let { edit { putString(it.key, encode(it)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a whole queue in one go.
|
||||
*
|
||||
* Queueing a season is one call, not one per episode: `commit()` per record
|
||||
* on a 200-episode series was 200 synchronous fsyncs, and the callers are
|
||||
* click handlers.
|
||||
*/
|
||||
fun putAll(records: Collection<RetainedDownload>) = synchronized(WRITES) {
|
||||
edit { records.forEach { putString(it.key, encode(it)) } }
|
||||
}
|
||||
|
||||
fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
|
||||
edit { remove(key(shareId, linkId)) }
|
||||
}
|
||||
|
||||
/** Forget every retained download. Callers cancel the work first. */
|
||||
fun clear() = synchronized(WRITES) { edit { clear() } }
|
||||
|
||||
fun removeShare(shareId: String) = synchronized(WRITES) {
|
||||
edit { records().filter { it.shareId == shareId }.forEach { remove(it.key) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `apply()`, not `commit()`.
|
||||
*
|
||||
* The in-memory map is updated before this returns, so a read that follows a
|
||||
* write in the same process still sees it; only the disk write is deferred,
|
||||
* and losing the newest progress reading to a kill is not worth an fsync on
|
||||
* whichever thread happened to be writing.
|
||||
*/
|
||||
private inline fun edit(block: android.content.SharedPreferences.Editor.() -> Unit) {
|
||||
preferences.edit().apply(block).apply()
|
||||
}
|
||||
|
||||
private fun encode(record: RetainedDownload) = JSONObject()
|
||||
.put("share", record.shareId)
|
||||
.put("volume", record.volumeId)
|
||||
.put("link", record.linkId)
|
||||
.put("label", record.label)
|
||||
.put("downloaded", record.downloaded)
|
||||
.put("total", record.total)
|
||||
.put("status", record.status)
|
||||
.put("error", record.error)
|
||||
.toString()
|
||||
|
||||
private fun decode(encoded: String): RetainedDownload {
|
||||
val json = JSONObject(encoded)
|
||||
return RetainedDownload(
|
||||
shareId = json.getString("share"),
|
||||
volumeId = json.getString("volume"),
|
||||
linkId = json.getString("link"),
|
||||
label = json.optString("label", json.getString("link")),
|
||||
downloaded = json.optLong("downloaded"),
|
||||
total = json.optLong("total"),
|
||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||
error = json.optString("error").takeIf(String::isNotBlank),
|
||||
)
|
||||
}
|
||||
|
||||
private fun key(shareId: String, linkId: String) = "$shareId\u001f$linkId"
|
||||
|
||||
private companion object {
|
||||
const val NAME = "offline_download_state"
|
||||
|
||||
/**
|
||||
* One lock for the whole process. Instances of this class are created
|
||||
* ad hoc from a `Context`, so the lock cannot live on one of them.
|
||||
*/
|
||||
val WRITES = Any()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import uniffi.pstr_android.DownloadObserver
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Durable boundary for native offline downloads. Rust owns the block-aligned transfer and
|
||||
* catalog mutation; WorkManager owns constraints, retries and user-visible lifecycle.
|
||||
*/
|
||||
class OfflineDownloadWorker(
|
||||
appContext: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(0))
|
||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
val store = DownloadStateStore(applicationContext)
|
||||
var retained = store.get(shareId, linkId)
|
||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
||||
store.put(retained)
|
||||
|
||||
val observer = object : DownloadObserver {
|
||||
private var reportedAt = 0L
|
||||
private var reportedPercent = -1
|
||||
|
||||
/**
|
||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||
* link, each one a WorkManager database write, a notification post
|
||||
* and a preferences write. Nothing the viewer can see changes that
|
||||
* often, so a report is only made when the percentage moves or a
|
||||
* second has passed. The final one is never suppressed.
|
||||
*/
|
||||
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val complete = total > 0UL && downloaded >= total
|
||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
reportedAt = now
|
||||
reportedPercent = percent
|
||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||
setForegroundAsync(createForegroundInfo(percent))
|
||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||
// this read-modify-write used to put RUNNING back over the
|
||||
// viewer's PAUSED and then report the download as cancelled.
|
||||
store.update(shareId, linkId) { stored ->
|
||||
val current = stored ?: retained
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
RetainedDownload.STATUS_CANCELLED,
|
||||
-> current.status
|
||||
else -> RetainedDownload.STATUS_RUNNING
|
||||
},
|
||||
).also { retained = it }
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCancelled(): Boolean = isStopped
|
||||
}
|
||||
return runCatching {
|
||||
NativeRuntime.engine().downloadEpisode(shareId, volumeId, linkId, observer)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
store.remove(shareId, linkId)
|
||||
Result.success()
|
||||
},
|
||||
onFailure = { failure ->
|
||||
if (isStopped) {
|
||||
// A pause is also a stop. Under the lock, so the record read
|
||||
// here is the one the write lands on.
|
||||
store.update(shareId, linkId) { stored ->
|
||||
val current = stored ?: retained
|
||||
current.takeIf { it.status != RetainedDownload.STATUS_PAUSED }
|
||||
?.copy(status = RetainedDownload.STATUS_CANCELLED)
|
||||
}
|
||||
Result.failure(error("cancelled"))
|
||||
} else if (runAttemptCount < MAX_RETRIES) {
|
||||
store.put(retained.copy(status = RetainedDownload.STATUS_QUEUED, error = failure.message))
|
||||
Result.retry()
|
||||
} else {
|
||||
val message = failure.message ?: "download failed"
|
||||
store.put(retained.copy(status = RetainedDownload.STATUS_FAILED, error = message))
|
||||
Result.failure(error(message))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Once per run, rather than once per 4 MiB block. */
|
||||
private fun createNotificationChannel() {
|
||||
applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle("Making episode available offline")
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return ForegroundInfo(
|
||||
stableNotificationId(id),
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
|
||||
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||
|
||||
companion object {
|
||||
const val KEY_SHARE_ID = "share_id"
|
||||
const val KEY_VOLUME_ID = "volume_id"
|
||||
const val KEY_LINK_ID = "link_id"
|
||||
const val KEY_LABEL = "label"
|
||||
const val KEY_ERROR = "error"
|
||||
const val KEY_DOWNLOADED = "downloaded"
|
||||
const val KEY_TOTAL = "total"
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val CHANNEL_ID = "offline-downloads"
|
||||
|
||||
/** Shortest gap between two identical-looking progress reports. */
|
||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
internal fun stableNotificationId(workId: UUID): Int =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||
@@ -0,0 +1,101 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import uniffi.pstr_android.AndroidSecretStore
|
||||
import uniffi.pstr_android.BridgeException
|
||||
|
||||
/**
|
||||
* Encrypts share fragments and passwords before they reach app preferences.
|
||||
*
|
||||
* Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
|
||||
* the FFI have no mapping on the Rust side, and an unmapped callback exception
|
||||
* is a panic on whichever thread was calling — including a tokio worker holding
|
||||
* a download. What the caller does about it is a separate question; being told
|
||||
* at all is this class's part.
|
||||
*/
|
||||
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
|
||||
override fun set(key: String, value: String) = bridging("store credential") {
|
||||
val payload = try {
|
||||
seal(value)
|
||||
} catch (invalidated: KeyPermanentlyInvalidatedException) {
|
||||
// A lockscreen change or a device restore retires the key. Nothing
|
||||
// it sealed can be read again — and nothing new can be sealed with
|
||||
// it either, so re-entering a link would fail here rather than
|
||||
// restoring access. Everything the old key held is already lost, so
|
||||
// replacing it costs nothing that is not gone.
|
||||
keyStore.deleteEntry(KEY_ALIAS)
|
||||
seal(value)
|
||||
}
|
||||
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
||||
}
|
||||
|
||||
private fun seal(value: String): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||
return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
override fun get(key: String): String? = bridging("read credential") {
|
||||
val encoded = preferences.getString(key, null) ?: return@bridging null
|
||||
val payload = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
encryptionKey(),
|
||||
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
||||
)
|
||||
cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
override fun delete(key: String) = bridging("delete credential") {
|
||||
check(preferences.edit().remove(key).commit())
|
||||
}
|
||||
|
||||
private fun <T> bridging(action: String, block: () -> T): T = try {
|
||||
block()
|
||||
} catch (error: BridgeException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
// The message, never the stack trace: this reaches a snackbar, and the
|
||||
// trace is both unreadable there and a description of app internals.
|
||||
throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
|
||||
}
|
||||
|
||||
private fun encryptionKey(): SecretKey {
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build(),
|
||||
)
|
||||
generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "proton_stream_secrets"
|
||||
const val KEY_ALIAS = "proton-stream-share-secrets-v1"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val IV_SIZE = 12
|
||||
const val TAG_BITS = 128
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
||||
object NativeRuntime {
|
||||
private lateinit var appContext: Context
|
||||
@Volatile private var instance: AndroidEngine? = null
|
||||
|
||||
/**
|
||||
* Whether Android's certificate verifier came up.
|
||||
*
|
||||
* False means every HTTPS request will fail validation, which otherwise
|
||||
* surfaces much later as an unexplained certificate error against a share.
|
||||
*/
|
||||
@Volatile var tlsReady: Boolean = false
|
||||
private set
|
||||
|
||||
fun initialize(context: Context) {
|
||||
System.loadLibrary("pstr_android")
|
||||
tlsReady = initTls(context)
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/** Initializes Android's certificate verifier before Proton networking. */
|
||||
private external fun initTls(context: Context): Boolean
|
||||
|
||||
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
||||
instance ?: synchronized(this@NativeRuntime) {
|
||||
instance ?: createEngine().also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored palette, without building the engine.
|
||||
*
|
||||
* Callable from the main thread, and meant to be: it is the only palette
|
||||
* read early enough to decide what the first frame is painted in, and
|
||||
* [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a
|
||||
* Keystore key. This reads one small JSON file and resolves it.
|
||||
*/
|
||||
fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths())
|
||||
|
||||
private fun paths(): AndroidPaths {
|
||||
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
|
||||
val config = File(appContext.noBackupFilesDir, "config")
|
||||
val data = File(appContext.filesDir, "data")
|
||||
val cache = File(appContext.cacheDir, "stream")
|
||||
return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
|
||||
}
|
||||
|
||||
private fun createEngine(): AndroidEngine =
|
||||
AndroidEngine(paths(), KeystoreSecretStore(appContext))
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.ui.ThumbnailSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import uniffi.pstr_android.ChapterPlan
|
||||
import uniffi.pstr_android.ChapterRecord
|
||||
import uniffi.pstr_android.chapterPlan
|
||||
|
||||
/**
|
||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||
* agree on what "finished" means.
|
||||
*
|
||||
* Only [Eof] is an episode watched to the end. Treating the others as one — a
|
||||
* stop, a shutdown, a failed demux — is what marks an episode that never played
|
||||
* as watched and autoplays past it.
|
||||
*/
|
||||
enum class EndReason {
|
||||
None,
|
||||
Eof,
|
||||
Stopped,
|
||||
Quit,
|
||||
Failed,
|
||||
Other,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
|
||||
}
|
||||
}
|
||||
|
||||
data class MpvPlaybackState(
|
||||
val position: Double = 0.0,
|
||||
val duration: Double = 0.0,
|
||||
val volume: Double = 100.0,
|
||||
val paused: Boolean = true,
|
||||
val muted: Boolean = false,
|
||||
val ended: Boolean = false,
|
||||
/** Meaningful only while [ended]; [EndReason.None] otherwise. */
|
||||
val endReason: EndReason = EndReason.None,
|
||||
/**
|
||||
* Which episode this reading belongs to, or null before the first poll of a
|
||||
* newly loaded file. Everything that *writes* per-episode state — resume
|
||||
* position above all — must check this: one host serves every episode, and
|
||||
* a reading attributed to the wrong one resumes the next episode where the
|
||||
* last one stopped.
|
||||
*/
|
||||
val media: String? = null,
|
||||
/** The picture's display size, or zero until mpv has decoded enough to know. */
|
||||
val videoWidth: Double = 0.0,
|
||||
val videoHeight: Double = 0.0,
|
||||
/**
|
||||
* mpv has run out of buffered data and stopped to refill.
|
||||
*
|
||||
* Not the same as [paused], which is the viewer's doing. A frozen picture
|
||||
* that nothing explains is indistinguishable from a hung app, which is what
|
||||
* a network-backed stream looked like before this existed.
|
||||
*/
|
||||
val buffering: Boolean = false,
|
||||
/** How full the demuxer cache is while [buffering], 0–100. */
|
||||
val cachePercent: Double = 0.0,
|
||||
/** A seek has been issued but playback has not resumed at the new position. */
|
||||
val seeking: Boolean = false,
|
||||
) {
|
||||
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
||||
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
||||
}
|
||||
|
||||
/**
|
||||
* What is on screen, for the surfaces that are not the player.
|
||||
*
|
||||
* The media session, its notification and Picture-in-Picture all have to name
|
||||
* the episode, and none of them can reach the composition that knows it. The
|
||||
* player publishes this once per episode and they read it.
|
||||
*/
|
||||
data class NowPlaying(
|
||||
val media: String,
|
||||
val title: String,
|
||||
val show: String?,
|
||||
val detail: String?,
|
||||
val artworkUrl: String?,
|
||||
/** The file to pull a Proton thumbnail from when the provider has no poster. */
|
||||
val artworkFile: ThumbnailSource?,
|
||||
val hasPrevious: Boolean,
|
||||
val hasNext: Boolean,
|
||||
)
|
||||
|
||||
data class MpvTrack(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val title: String,
|
||||
val selected: Boolean,
|
||||
) {
|
||||
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||
}
|
||||
|
||||
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
||||
class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
|
||||
private val lifecycle = ReentrantLock()
|
||||
@Volatile private var closed = false
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
||||
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
|
||||
private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
|
||||
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||
private val mutableAdvancing = MutableStateFlow(false)
|
||||
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||
private var poller: Job? = null
|
||||
/** The episode key and load generation the native player currently holds. */
|
||||
@Volatile private var currentMedia: String? = null
|
||||
@Volatile private var currentGeneration: Double = -1.0
|
||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||
var onPlaybackStarted: (() -> Unit)? = null
|
||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||
var onExplicitStop: (() -> Unit)? = null
|
||||
/**
|
||||
* Where a transport outside the player sends previous/next.
|
||||
*
|
||||
* The player owns which episode is open, so the notification, the lock
|
||||
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
|
||||
* run on the main thread, which is where the player's state lives.
|
||||
*/
|
||||
var onSkipPrevious: (() -> Unit)? = null
|
||||
var onSkipNext: (() -> Unit)? = null
|
||||
|
||||
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
||||
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
|
||||
/** The open file's chapters, already resolved into openings and endings. */
|
||||
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
|
||||
/** The episode the player has open, for every surface that has to name it. */
|
||||
val nowPlaying: StateFlow<NowPlaying?> = mutableNowPlaying.asStateFlow()
|
||||
/**
|
||||
* True while the player is between episodes.
|
||||
*
|
||||
* The end of one episode and the start of the next are separated by a
|
||||
* network round trip — opening the next revision's stream. The foreground
|
||||
* service must not retire in that gap: with no foreground state left, the
|
||||
* `startForeground` on the other side is a background start, which API 31+
|
||||
* refuses outright.
|
||||
*/
|
||||
val advancing: StateFlow<Boolean> = mutableAdvancing.asStateFlow()
|
||||
/**
|
||||
* What mpv last complained about, until the viewer dismisses it.
|
||||
*
|
||||
* Errors mpv reports are about the *file* — an unreadable header, a codec it
|
||||
* has no decoder for — and reach nothing else: the play call has already
|
||||
* returned successfully by the time they happen.
|
||||
*/
|
||||
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||
|
||||
/** Dismiss the current [problem]. */
|
||||
fun clearProblem() { mutableProblem.value = null }
|
||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||
|
||||
override fun attachSurface(surface: Surface) {
|
||||
withOpenHandle { nativeAttachSurface(it, surface) }
|
||||
}
|
||||
|
||||
override fun detachSurface() {
|
||||
withOpenHandle { nativeDetachSurface(it) }
|
||||
}
|
||||
|
||||
override suspend fun play(
|
||||
media: String,
|
||||
nativeHandle: ULong,
|
||||
size: ULong,
|
||||
startPosition: Double,
|
||||
audioLanguage: String?,
|
||||
subtitleLanguage: String?,
|
||||
subtitles: Boolean,
|
||||
) {
|
||||
check(size > 0uL) { "Cannot play an empty stream" }
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val loaded = withOpenHandle {
|
||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
||||
if (loaded) {
|
||||
// The outgoing stream is released by mpv closing it,
|
||||
// not from here: `loadfile` is asynchronous and its
|
||||
// demuxer may still be reading. `stream_close` in
|
||||
// `pstr_mpv.cpp` owns that release.
|
||||
//
|
||||
// The outgoing file's readings describe an episode
|
||||
// that is no longer open. Retire them here as well
|
||||
// as natively, so nothing observes them even once.
|
||||
currentMedia = media
|
||||
currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
|
||||
mutableState.value = MpvPlaybackState(
|
||||
position = startPosition,
|
||||
volume = mutableState.value.volume,
|
||||
muted = mutableState.value.muted,
|
||||
media = media,
|
||||
)
|
||||
mutableProblem.value = null
|
||||
mutableTracks.value = emptyList()
|
||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||
plannedFor = null
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
check(loaded) {
|
||||
"libmpv rejected the stream or no video surface was available"
|
||||
}
|
||||
}
|
||||
withOpenHandle {
|
||||
onPlaybackStarted?.invoke()
|
||||
startPolling()
|
||||
}
|
||||
} finally {
|
||||
// Whether the next episode arrived or failed to, the gap the
|
||||
// foreground service was holding open is over.
|
||||
mutableAdvancing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that the player is walking to another episode.
|
||||
*
|
||||
* Called before the stream for the next one is opened, and cleared when
|
||||
* [play] returns either way — the window this covers is the one in which
|
||||
* nothing is loaded and the service would otherwise conclude playback is
|
||||
* finished.
|
||||
*/
|
||||
fun beginTransition() { mutableAdvancing.value = true }
|
||||
|
||||
/** Names the open episode for the media session, its notification and PiP. */
|
||||
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
// The native side writes the flag through, so every transport can flip
|
||||
// now instead of on the next poll a quarter second from here.
|
||||
val next = withOpenHandle {
|
||||
nativePause(it, paused)
|
||||
mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
|
||||
} ?: return
|
||||
onStateChanged?.invoke(next)
|
||||
}
|
||||
|
||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||
|
||||
/** Playback rate, 1.0 being the file's own. Clamped native-side. */
|
||||
fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
|
||||
fun selectTrack(track: MpvTrack?) {
|
||||
val audio = track?.type == "audio"
|
||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||
refreshTracks()
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit user stop: stop media and retire the foreground service.
|
||||
*
|
||||
* The stream behind the open file is released by mpv closing it, which
|
||||
* `stop` causes; releasing it from here would race that close.
|
||||
*/
|
||||
fun stop() {
|
||||
val callback = withOpenHandle {
|
||||
nativeStop(it)
|
||||
// Nothing loaded means nothing to attribute a reading to: a save
|
||||
// issued after this must not land on the episode just stopped.
|
||||
currentMedia = null
|
||||
mutableAdvancing.value = false
|
||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||
mutableNowPlaying.value = null
|
||||
onExplicitStop
|
||||
}
|
||||
callback?.invoke()
|
||||
}
|
||||
|
||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||
|
||||
private fun startPolling() {
|
||||
if (poller?.isActive == true) return
|
||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||
// are synchronous `mpv_get_property` calls — five per track for the
|
||||
// latter — that contend on the core lock the demuxer holds during a
|
||||
// fetch. A poll that blocks there blocks the UI thread with it.
|
||||
poller = scope.launch(Dispatchers.Default) {
|
||||
var tick = 0
|
||||
while (isActive) {
|
||||
val values = withOpenHandle(::nativeState) ?: break
|
||||
if (values.size >= FIELDS) {
|
||||
val next = MpvPlaybackState(
|
||||
values[0], values[1], values[2],
|
||||
values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
|
||||
// Only a reading of the file this host was last asked
|
||||
// to load carries its episode's name.
|
||||
media = currentMedia.takeIf { values.generation() == currentGeneration },
|
||||
videoWidth = values[WIDTH],
|
||||
videoHeight = values[HEIGHT],
|
||||
endReason = EndReason.of(values[END_REASON]),
|
||||
buffering = values[BUFFERING] != 0.0,
|
||||
cachePercent = values[CACHE_PERCENT],
|
||||
seeking = values[SEEKING] != 0.0,
|
||||
)
|
||||
mutableState.value = next
|
||||
// The listeners drive the media session and the player UI,
|
||||
// both of which are the main thread's.
|
||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||
refreshChapters(next)
|
||||
}
|
||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||
if (tick++ % 4 == 0) refreshTracks()
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the chapters when there is a reason to: a different file, or a
|
||||
* duration that has only just arrived. Both change the answer — the length
|
||||
* of a chapter is what settles whether `Intro` is an opening or ten minutes
|
||||
* of story — and neither changes again after that, so this settles quickly
|
||||
* and then costs one comparison per poll.
|
||||
*/
|
||||
private fun refreshChapters(state: MpvPlaybackState) {
|
||||
val generation = currentGeneration
|
||||
val wanted = state.duration to generation.toRawBits()
|
||||
if (plannedFor == wanted) return
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeChapters) ?: return
|
||||
val values = JSONArray(encoded)
|
||||
List(values.length()) { index ->
|
||||
values.getJSONObject(index).run {
|
||||
ChapterRecord(
|
||||
index = getLong("index"),
|
||||
title = optString("title").takeIf(String::isNotBlank),
|
||||
start = getDouble("start"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onSuccess { chapters ->
|
||||
plannedFor = wanted
|
||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshTracks() {
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||
val values = JSONArray(encoded)
|
||||
List(values.length()) { index ->
|
||||
values.getJSONObject(index).run {
|
||||
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
||||
}
|
||||
}
|
||||
}.onSuccess { mutableTracks.value = it }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
val destroy = lifecycle.withLock {
|
||||
if (closed) return@withLock false
|
||||
closed = true
|
||||
onPlaybackStarted = null
|
||||
onStateChanged = null
|
||||
onExplicitStop = null
|
||||
onSkipPrevious = null
|
||||
onSkipNext = null
|
||||
mutableNowPlaying.value = null
|
||||
mutableAdvancing.value = false
|
||||
true
|
||||
}
|
||||
if (!destroy) return
|
||||
// `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
|
||||
// parked in a block fetch over a degraded connection. This is called
|
||||
// from Service.onDestroy — on the main thread — so waiting for it there
|
||||
// is an ANR. `closed` is already set, so nothing new enters the handle;
|
||||
// the lock is only to let a call that is already inside finish.
|
||||
//
|
||||
// Terminating the core closes the open file, and closing it releases the
|
||||
// stream — after mpv's last read rather than during it.
|
||||
Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
|
||||
}
|
||||
|
||||
/** Serializes destruction against every JNI use of the raw Player pointer. */
|
||||
private inline fun <T> withOpenHandle(block: (Long) -> T): T? = lifecycle.withLock {
|
||||
if (closed) null else block(nativeHandle)
|
||||
}
|
||||
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||
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 nativePause(handle: Long, paused: Boolean)
|
||||
private external fun nativeSeek(handle: Long, position: Double)
|
||||
private external fun nativeVolume(handle: Long, volume: Double)
|
||||
private external fun nativeSpeed(handle: Long, speed: Double)
|
||||
private external fun nativeMute(handle: Long, muted: Boolean)
|
||||
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
||||
private external fun nativeStop(handle: Long)
|
||||
private external fun nativeState(handle: Long): DoubleArray
|
||||
private external fun nativeTakeError(handle: Long): String?
|
||||
// Nullable: `NewStringUTF` returns null when the string cannot be built,
|
||||
// and a non-null declaration turns that into an NPE that `runCatching`
|
||||
// swallows into a silently empty track list.
|
||||
private external fun nativeTracks(handle: Long): String?
|
||||
private external fun nativeChapters(handle: Long): String?
|
||||
private external fun pstrAndroidStreamRelease(handle: Long)
|
||||
|
||||
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
|
||||
|
||||
companion object {
|
||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||
private const val FIELDS = 13
|
||||
private const val GENERATION = 6
|
||||
private const val WIDTH = 7
|
||||
private const val HEIGHT = 8
|
||||
private const val END_REASON = 9
|
||||
private const val BUFFERING = 10
|
||||
private const val CACHE_PERCENT = 11
|
||||
private const val SEEKING = 12
|
||||
|
||||
init {
|
||||
System.loadLibrary("pstr_android")
|
||||
System.loadLibrary("pstr_mpv")
|
||||
}
|
||||
|
||||
/**
|
||||
* A host over a live libmpv core, or null if libmpv would not start.
|
||||
*
|
||||
* Null rather than an exception: the service that owns this is created
|
||||
* by the system, so a throw out of the constructor takes the process
|
||||
* with it — and the app has a graceful "this build does not include
|
||||
* libmpv" screen that a crash makes unreachable.
|
||||
*/
|
||||
fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
|
||||
|
||||
@JvmStatic private external fun nativeCreate(): Long
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Icon
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.loadArtwork
|
||||
import io.narl.protonstream.ui.loadThumbnail
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||
class PlaybackService : Service() {
|
||||
inner class PlaybackBinder : Binder() {
|
||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||
}
|
||||
|
||||
/**
|
||||
* The libmpv core, or null when this build could not start one.
|
||||
*
|
||||
* Nullable rather than a `lateinit` that throws: the system creates this
|
||||
* service, so a failure in its constructor takes the process down and makes
|
||||
* the app's own "playback is unavailable" screen unreachable.
|
||||
*/
|
||||
private var host: NativeMpvHost? = null
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var audioManager: AudioManager
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var audioFocus: AudioFocusRequest? = null
|
||||
/** Whether the pause on screen is this service's doing, not the viewer's. */
|
||||
private var pausedForFocus = false
|
||||
/** The volume before ducking, so the restore does not read a ducked one. */
|
||||
private var beforeDucking: Double? = null
|
||||
/**
|
||||
* Pulling the headphone jack must not continue on the speaker.
|
||||
*
|
||||
* Android broadcasts this and expects every media app to handle it; the
|
||||
* alternative is an episode playing out loud in a quiet room.
|
||||
*/
|
||||
private val becomingNoisy = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
|
||||
}
|
||||
}
|
||||
private var foregroundPlayback = false
|
||||
private var sessionPlayback = false
|
||||
/**
|
||||
* What the posted notification already says.
|
||||
*
|
||||
* The state poll runs four times a second and almost none of those readings
|
||||
* change anything the shade shows. Re-posting on every one of them animates
|
||||
* the media control and costs a binder round trip per tick, so the
|
||||
* notification is rebuilt only when its own inputs move.
|
||||
*/
|
||||
private var notified: NotificationShape? = null
|
||||
/** The pending teardown, held so an episode transition can call it off. */
|
||||
private var retire: Job? = null
|
||||
/** The episode's artwork, and the source key it was fetched for. */
|
||||
private var artwork: Pair<String, Bitmap>? = null
|
||||
private val binder = PlaybackBinder()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
becomingNoisy,
|
||||
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
audioManager = getSystemService(AudioManager::class.java)
|
||||
host = NativeMpvHost.createOrNull()?.apply {
|
||||
onPlaybackStarted = {
|
||||
// A refusal is rare — a call in progress — but playing over it
|
||||
// is worse than not playing, and the viewer can simply press
|
||||
// play again once the call ends.
|
||||
if (!requestAudioFocus()) setPaused(true)
|
||||
sessionPlayback = true
|
||||
cancelRetire()
|
||||
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||
// Convert the bound service into a started foreground
|
||||
// service only when the viewer opted to keep audio alive.
|
||||
// Already-foreground is the ordinary case across an episode
|
||||
// transition, and re-entering costs a notification rebuild.
|
||||
foregroundPlayback = runCatching {
|
||||
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
|
||||
}.isSuccess
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
setSessionActivity(openActivity())
|
||||
setCallback(object : MediaSession.Callback() {
|
||||
override fun onPlay() { host?.setPaused(false) }
|
||||
override fun onPause() { host?.setPaused(true) }
|
||||
override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
|
||||
override fun onStop() { host?.stop() }
|
||||
override fun onSkipToNext() { host?.onSkipNext?.invoke() }
|
||||
override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
|
||||
})
|
||||
}
|
||||
// Artwork arrives after the episode does, and the shape of the transport
|
||||
// changes with the playlist position, so the notification follows the
|
||||
// published episode rather than being built once when playback starts.
|
||||
val core = host ?: return
|
||||
scope.launch {
|
||||
core.nowPlaying.collect { playing ->
|
||||
// Keyed by whichever source the episode has: with metadata off
|
||||
// there is no provider poster, and Proton's own thumbnail is
|
||||
// the only thing the shade can show.
|
||||
artwork = playing?.let { episode ->
|
||||
val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
|
||||
artwork?.takeIf { it.first == key }
|
||||
?: (
|
||||
loadArtwork(this@PlaybackService, episode.artworkUrl)
|
||||
?: loadThumbnail(episode.artworkFile)
|
||||
)?.let { key to it }
|
||||
}
|
||||
refreshSession(core.state.value, playing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_PLAY -> host?.setPaused(false)
|
||||
ACTION_PAUSE -> host?.setPaused(true)
|
||||
ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
|
||||
ACTION_NEXT -> host?.onSkipNext?.invoke()
|
||||
ACTION_STOP -> host?.stop()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
mediaSession.release()
|
||||
host?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Take audio focus, and honour what the system says about it afterwards.
|
||||
*
|
||||
* The four cases are genuinely different and collapsing them is what made
|
||||
* this feel broken: a phone call is transient and playback should come back
|
||||
* by itself; a navigation prompt wants the volume down, not a pause; another
|
||||
* media app taking over for good means giving the focus back rather than
|
||||
* holding it; and a refusal means not playing at all.
|
||||
*/
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||
)
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setOnAudioFocusChangeListener { focus ->
|
||||
when (focus) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
pausedForFocus = false
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
audioFocus = null
|
||||
host?.setPaused(true)
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
// Only resume what this paused: a viewer who paused
|
||||
// before the phone rang expects it still paused after.
|
||||
pausedForFocus = host?.state?.value?.paused == false
|
||||
host?.setPaused(true)
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
host?.let { core ->
|
||||
val current = core.state.value.volume
|
||||
beforeDucking = beforeDucking ?: current
|
||||
core.setVolume(current * DUCK_FACTOR)
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
beforeDucking?.let { host?.setVolume(it) }
|
||||
beforeDucking = null
|
||||
if (pausedForFocus) {
|
||||
pausedForFocus = false
|
||||
host?.setPaused(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
audioFocus = request
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
refreshSession(state, host?.nowPlaying?.value)
|
||||
if (state.ended) scheduleRetire() else cancelRetire()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the session once playback is really over.
|
||||
*
|
||||
* "This file ended" is not "playback is over": autoplay walks to the next
|
||||
* episode, and opening its stream is a network round trip. Retiring inside
|
||||
* that gap leaves the app with no foreground state at all, so the
|
||||
* `startForeground` on the far side is a background start — which API 31+
|
||||
* refuses with `ForegroundServiceStartNotAllowedException`, and this app's
|
||||
* minSdk is 31.
|
||||
*/
|
||||
private fun scheduleRetire() {
|
||||
if (retire?.isActive == true) return
|
||||
retire = scope.launch {
|
||||
// The end of file reaches the player and this service from the same
|
||||
// poll, and the player's advance is a recomposition behind. Give it
|
||||
// that long to declare a transition before concluding there is none.
|
||||
delay(TRANSITION_SETTLE_MS)
|
||||
val host = this@PlaybackService.host ?: return@launch
|
||||
withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
|
||||
if (host.state.value.ended) retirePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelRetire() {
|
||||
retire?.cancel()
|
||||
retire = null
|
||||
}
|
||||
|
||||
private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
|
||||
val status = when {
|
||||
state.ended -> PlaybackState.STATE_STOPPED
|
||||
state.paused -> PlaybackState.STATE_PAUSED
|
||||
else -> PlaybackState.STATE_PLAYING
|
||||
}
|
||||
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
|
||||
PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
|
||||
if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackState.Builder()
|
||||
.setActions(actions)
|
||||
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
||||
.build(),
|
||||
)
|
||||
mediaSession.setMetadata(metadata(state, playing))
|
||||
val shape = NotificationShape(
|
||||
paused = state.paused,
|
||||
media = playing?.media,
|
||||
title = playing?.title,
|
||||
subtitle = playing?.show,
|
||||
hasPrevious = playing?.hasPrevious == true,
|
||||
hasNext = playing?.hasNext == true,
|
||||
hasArtwork = artwork != null,
|
||||
)
|
||||
if (foregroundPlayback && notified != shape) {
|
||||
notified = shape
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notification(state, playing))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the shade, the lock screen and Android Auto read.
|
||||
*
|
||||
* The episode is the title and the show is the artist, which is the mapping
|
||||
* every media surface already lays out well: one prominent line, one
|
||||
* secondary, and the poster behind both.
|
||||
*/
|
||||
private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
|
||||
MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
|
||||
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
|
||||
.apply {
|
||||
playing?.show?.let {
|
||||
putString(MediaMetadata.METADATA_KEY_ARTIST, it)
|
||||
putString(MediaMetadata.METADATA_KEY_ALBUM, it)
|
||||
putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
|
||||
}
|
||||
playing?.detail?.let {
|
||||
putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
|
||||
}
|
||||
artwork?.second?.let {
|
||||
putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
|
||||
putBitmap(MediaMetadata.METADATA_KEY_ART, it)
|
||||
putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
|
||||
}
|
||||
}
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
|
||||
.build()
|
||||
|
||||
private fun retirePlayback() {
|
||||
cancelRetire()
|
||||
if (!sessionPlayback && !foregroundPlayback) return
|
||||
sessionPlayback = false
|
||||
notified = null
|
||||
artwork = null
|
||||
mediaSession.isActive = false
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
audioFocus = null
|
||||
if (foregroundPlayback) stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
foregroundPlayback = false
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun openActivity(): PendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(this, PlaybackService::class.java).setAction(action),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
|
||||
Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
|
||||
|
||||
private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
|
||||
val paused = state.paused
|
||||
val actions = buildList {
|
||||
if (playing?.hasPrevious == true) {
|
||||
add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
|
||||
}
|
||||
add(
|
||||
if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
|
||||
else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
|
||||
)
|
||||
if (playing?.hasNext == true) {
|
||||
add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
|
||||
}
|
||||
add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
|
||||
}
|
||||
val style = Notification.MediaStyle()
|
||||
.setMediaSession(mediaSession.sessionToken)
|
||||
// The compact view holds three: the transport as it exists for this
|
||||
// episode, so a first episode does not show a dead Previous.
|
||||
.setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle(playing?.title ?: "proton-stream")
|
||||
.setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
|
||||
.apply {
|
||||
playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
|
||||
artwork?.second?.let { setLargeIcon(it) }
|
||||
}
|
||||
.setColor(ACCENT)
|
||||
.setColorized(true)
|
||||
.setContentIntent(openActivity())
|
||||
.setDeleteIntent(command(2, ACTION_STOP))
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.setOngoing(!paused)
|
||||
.apply { actions.forEach { addAction(it) } }
|
||||
.setStyle(style)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Transport controls for the episode being played."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The inputs the posted notification is built from, and nothing else. */
|
||||
private data class NotificationShape(
|
||||
val paused: Boolean,
|
||||
val media: String?,
|
||||
val title: String?,
|
||||
val subtitle: String?,
|
||||
val hasPrevious: Boolean,
|
||||
val hasNext: Boolean,
|
||||
val hasArtwork: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "playback"
|
||||
private const val NOTIFICATION_ID = 47
|
||||
/** How long a player gets to declare that it is changing episodes. */
|
||||
private const val TRANSITION_SETTLE_MS = 1_000L
|
||||
/** And how long that transition may then take before it is abandoned. */
|
||||
private const val TRANSITION_GRACE_MS = 30_000L
|
||||
/** How far the volume drops for a navigation prompt or a notification. */
|
||||
private const val DUCK_FACTOR = 0.3
|
||||
|
||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
|
||||
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
||||
private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
|
||||
private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
|
||||
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Toc
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Audiotrack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Forward30
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Replay10
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material.icons.filled.Subtitles
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.AccentTrack
|
||||
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.thumbnailSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.ChapterEntry
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TrackPreferencesRecord
|
||||
import uniffi.pstr_android.skipOffer
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** How long the controls stay up after a tap. */
|
||||
private const val CONTROLS_SECONDS = 4L
|
||||
|
||||
/** How long the "up next" card counts down before it loads the next episode. */
|
||||
private const val UP_NEXT_SECONDS = 10
|
||||
|
||||
/** Past this much of an episode it counts as watched. */
|
||||
private const val WATCHED_FRACTION = 0.9
|
||||
|
||||
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||
private const val MAX_VOLUME = 100f
|
||||
|
||||
/**
|
||||
* The narrowest layout the in-app volume slider is worth the room it costs.
|
||||
*
|
||||
* Below it — a phone held upright — the transport row is already five controls
|
||||
* wide, and the device's own volume keys are the better answer.
|
||||
*/
|
||||
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
|
||||
|
||||
/**
|
||||
* The player, as its own screen on the activity's own window.
|
||||
*
|
||||
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
|
||||
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
|
||||
* activity window is what makes "fullscreen" mean the whole panel, notch
|
||||
* included.
|
||||
*
|
||||
* [episodes] is the title's episodes in display order and [index] the one that is
|
||||
* open, which is what previous/next and autoplay walk. The index is hoisted so
|
||||
* that it survives process recreation with the rest of the navigation state —
|
||||
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
|
||||
*
|
||||
* In [inPictureInPicture] the window is a thumbnail with the system's own
|
||||
* controls over it, so this screen draws the picture and nothing else.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
title: TitleRecord,
|
||||
episodes: List<EpisodeRecord>,
|
||||
index: Int,
|
||||
host: LibmpvHost?,
|
||||
inPictureInPicture: Boolean,
|
||||
minimized: Boolean,
|
||||
onIndexChange: (Int) -> Unit,
|
||||
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
|
||||
onMinimize: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val titleKey = title.key
|
||||
val episode = episodes.getOrNull(index)
|
||||
|
||||
// Minimised, this screen keeps the stream and the effects that own it, and
|
||||
// draws nothing: no surface — mpv plays on without one, exactly as it does
|
||||
// while the app is in the background — no immersive window, and no back
|
||||
// handler, because back belongs to the page the viewer went to.
|
||||
if (!minimized) {
|
||||
ImmersiveWindow(activity)
|
||||
// Back leaves the video rather than ending it. Stopping is the mini
|
||||
// transport's ✕, which is the one place it is unambiguous.
|
||||
BackHandler(onBack = onMinimize)
|
||||
}
|
||||
|
||||
// The transport reads state only this host publishes, so the interface
|
||||
// alone is not enough to draw a player.
|
||||
val nativeHost = host as? NativeMpvHost
|
||||
if (nativeHost == null || episode == null) {
|
||||
if (minimized) return
|
||||
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (nativeHost == null) "Playback is unavailable: this build does not include libmpv."
|
||||
else "This title has no episode to play.",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val key = mediaKey(episode)
|
||||
val session = remember(key) { RustStreamSession(episode) }
|
||||
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(session, host) {
|
||||
runCatching {
|
||||
val startup = withContext(Dispatchers.IO) {
|
||||
session.open()
|
||||
val engine = NativeRuntime.engine()
|
||||
// A choice made on this title wins; without one, the choice the
|
||||
// viewer made anywhere else does. Both are the desktop client's
|
||||
// fallback chain, and it is what makes "Japanese with English
|
||||
// subtitles" a thing you say once rather than once per show.
|
||||
val show = engine.titleTrackPreferences(titleKey)
|
||||
val global = engine.playbackPrefs()
|
||||
val watch = engine.watchState(episode.shareId, episode.linkId)
|
||||
Startup(
|
||||
nativeHandle = session.nativeHandle(),
|
||||
size = session.size(),
|
||||
// A finished episode restarts rather than resuming three
|
||||
// seconds from its own credits.
|
||||
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
|
||||
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
|
||||
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
|
||||
subtitles = show?.subtitles ?: global.subtitles,
|
||||
volume = global.volume,
|
||||
muted = global.muted,
|
||||
speed = global.speed,
|
||||
)
|
||||
}
|
||||
nativeHost.play(
|
||||
key, startup.nativeHandle, startup.size, startup.startPosition,
|
||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||
)
|
||||
// After the load, because mpv keeps none of the three across one.
|
||||
nativeHost.setVolume(startup.volume)
|
||||
nativeHost.setMuted(startup.muted)
|
||||
nativeHost.setSpeed(startup.speed)
|
||||
session.transferToPlayer()
|
||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||
}
|
||||
|
||||
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||
// this episode's, so detaching it belongs to the effect below and not here.
|
||||
DisposableEffect(session) { onDispose { session.close(nativeHost) } }
|
||||
DisposableEffect(nativeHost) { onDispose { nativeHost.detachSurface() } }
|
||||
|
||||
val state by nativeHost.state.collectAsState()
|
||||
val chapters by nativeHost.chapters.collectAsState()
|
||||
val tracks by nativeHost.tracks.collectAsState()
|
||||
// Read once: a preference the viewer changes mid-episode applies to the
|
||||
// next one, and re-reading the store per frame would not.
|
||||
var autoSkip by remember { mutableStateOf(false) }
|
||||
var autoplay by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||
.onSuccess { autoSkip = it.autoSkip; autoplay = it.autoplayNext }
|
||||
}
|
||||
|
||||
// Resume position, written only from readings that carry this episode's
|
||||
// name: one host serves every episode, and an unattributed reading is the
|
||||
// previous one's clock.
|
||||
fun save() {
|
||||
val current = nativeHost.state.value
|
||||
if (current.media == key && current.duration > 0.0) {
|
||||
onSaveProgress(
|
||||
episode,
|
||||
current.position.coerceIn(0.0, current.duration),
|
||||
current.duration,
|
||||
// Watched means played to the end. An episode that was stopped,
|
||||
// or that mpv could not play at all, also "ended" — recording
|
||||
// either as watched retires an episode nobody saw.
|
||||
current.endReason == EndReason.Eof ||
|
||||
current.position >= current.duration * WATCHED_FRACTION,
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(key) {
|
||||
while (true) {
|
||||
delay(5_000)
|
||||
save()
|
||||
}
|
||||
}
|
||||
DisposableEffect(key) { onDispose { save() } }
|
||||
|
||||
val next = episodes.getOrNull(index + 1)
|
||||
// Both walks tell the host they are one, so the foreground service holds
|
||||
// through the gap where nothing is loaded — see NativeMpvHost.advancing.
|
||||
fun advance() {
|
||||
save()
|
||||
next?.let { nativeHost.beginTransition(); onIndexChange(index + 1) }
|
||||
}
|
||||
fun retreat() {
|
||||
if (index == 0) return
|
||||
save()
|
||||
nativeHost.beginTransition()
|
||||
onIndexChange(index - 1)
|
||||
}
|
||||
|
||||
// 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
|
||||
// episode rather than the app.
|
||||
LaunchedEffect(key, index, episodes.size, title.key) {
|
||||
nativeHost.publish(
|
||||
NowPlaying(
|
||||
media = key,
|
||||
title = episode.label,
|
||||
show = title.canonicalName ?: title.name,
|
||||
detail = episode.detail,
|
||||
artworkUrl = title.posterUrl ?: title.backdropUrl,
|
||||
artworkFile = episode.thumbnailSource,
|
||||
hasPrevious = index > 0,
|
||||
hasNext = next != null,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Those same surfaces walk the playlist through here, because this is where
|
||||
// the open episode lives. Both callbacks run on the main thread.
|
||||
DisposableEffect(nativeHost, index, episodes.size) {
|
||||
nativeHost.onSkipNext = { advance() }
|
||||
nativeHost.onSkipPrevious = { retreat() }
|
||||
onDispose {
|
||||
nativeHost.onSkipNext = null
|
||||
nativeHost.onSkipPrevious = null
|
||||
}
|
||||
}
|
||||
|
||||
// Autoplay fires on a clean end of file only, and only for this episode's
|
||||
// own reading — never on a failure, and never on the outgoing file's.
|
||||
LaunchedEffect(state.endReason, state.media) {
|
||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
|
||||
}
|
||||
// A file mpv could not play ends like any other. Saying so is the whole
|
||||
// difference between a broken episode and an app that silently skipped one.
|
||||
LaunchedEffect(state.endReason, state.media) {
|
||||
if (state.endReason == EndReason.Failed && state.media == key) {
|
||||
playbackError = "This episode could not be played."
|
||||
}
|
||||
}
|
||||
// What mpv itself complained about, which the play call never sees: by the
|
||||
// time a header turns out to be unreadable it has long since returned.
|
||||
val problem by nativeHost.problem.collectAsState()
|
||||
LaunchedEffect(problem) { problem?.let { playbackError = it } }
|
||||
|
||||
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
|
||||
runCatching { skipOffer(chapters, state.position) }.getOrNull()
|
||||
}
|
||||
// Auto-skip is offered once per chapter: seeking back into an opening on
|
||||
// purpose must not be undone by the setting that skipped it.
|
||||
val skipped = remember(key) { mutableSetOf<Long>() }
|
||||
LaunchedEffect(offer?.target) {
|
||||
val target = offer?.target ?: return@LaunchedEffect
|
||||
val chapter = chapters.entries.lastOrNull { state.position + 0.001 >= it.start } ?: return@LaunchedEffect
|
||||
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
|
||||
}
|
||||
|
||||
var landscapeLocked by remember { mutableStateOf(false) }
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(showControls, state.paused) {
|
||||
if (showControls && !state.paused) {
|
||||
delay(CONTROLS_SECONDS * 1_000)
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
// Everything this screen draws over the picture is sized for a phone panel
|
||||
// and unreadable in a thumbnail, and Android supplies its own transport
|
||||
// there. Leaving PiP must find the controls up rather than mid-timeout.
|
||||
val overlays = !inPictureInPicture
|
||||
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
|
||||
|
||||
if (minimized) return
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
enabled = overlays,
|
||||
onClick = { showControls = !showControls },
|
||||
),
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { viewContext ->
|
||||
SurfaceView(viewContext).apply {
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
|
||||
host.attachSurface(holder.surface)
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = overlays && showControls,
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onMinimize) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Leave the video",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
// The episode leads and the show follows it: which episode is
|
||||
// the thing a viewer who has just woken the controls is
|
||||
// checking, and the show is what they already know.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
episode.label,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
|
||||
.joinToString(" • "),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
// Two ways out, because they mean different things: back leaves
|
||||
// the video and keeps the episode playing, this ends it.
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Stop playback", tint = Color.White)
|
||||
}
|
||||
remaining(state)?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = overlays && showControls,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
PlayerControls(
|
||||
titleKey = titleKey,
|
||||
host = nativeHost,
|
||||
state = state,
|
||||
chapters = chapters.entries,
|
||||
tracks = tracks,
|
||||
hasPrevious = index > 0,
|
||||
hasNext = next != null,
|
||||
onPrevious = { retreat() },
|
||||
onNext = { advance() },
|
||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
||||
landscapeLocked = landscapeLocked,
|
||||
)
|
||||
}
|
||||
|
||||
// Drawn whether or not the rest of the controls are up: an opening lasts
|
||||
// ninety seconds, and a button you have to wake with a tap is one nobody
|
||||
// reaches in time.
|
||||
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
|
||||
TonalButton(
|
||||
onClick = { nativeHost.seek(skip.target) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||
) { Text(skip.label) }
|
||||
}
|
||||
|
||||
UpNextCard(
|
||||
next = next,
|
||||
visible = overlays && autoplay && !state.paused &&
|
||||
chapters.creditsStart?.let { state.position >= it } == true,
|
||||
onPlayNow = { advance() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||
)
|
||||
|
||||
// Only while nothing else explains the still picture: an error is the
|
||||
// more useful thing to say, and a spinner over it reads as "still
|
||||
// trying".
|
||||
if (state.stalled && playbackError == null) {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
if (state.buffering) {
|
||||
Text(
|
||||
state.cachePercent.takeIf { it > 0.0 }
|
||||
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playbackError?.let { message ->
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.errorContainer, MaterialTheme.shapes.small)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(message, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
// Dismissible, because the message is not always fatal — a
|
||||
// subtitle track that failed to load leaves an episode that
|
||||
// plays perfectly well behind an error that used to be permanent.
|
||||
QuietButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
|
||||
Text("Dismiss", color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Startup(
|
||||
val nativeHandle: ULong,
|
||||
val size: ULong,
|
||||
val startPosition: Double,
|
||||
val audioLanguage: String?,
|
||||
val subtitleLanguage: String?,
|
||||
val subtitles: Boolean,
|
||||
val volume: Double,
|
||||
val muted: Boolean,
|
||||
val speed: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* Immersive for as long as this screen is up, and only that long.
|
||||
*
|
||||
* Hiding the bars is not enough on its own: without `setDecorFitsSystemWindows`
|
||||
* and a cutout mode the picture stops at the notch and at the gesture bar, which
|
||||
* is the "not really fullscreen" everyone notices.
|
||||
*/
|
||||
@Composable
|
||||
private fun ImmersiveWindow(activity: Activity?) {
|
||||
DisposableEffect(activity) {
|
||||
val window = activity?.window
|
||||
if (window == null) return@DisposableEffect onDispose {}
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
val previousCutout = window.attributes.layoutInDisplayCutoutMode
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
window.attributes = window.attributes.also {
|
||||
it.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
onDispose {
|
||||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
window.attributes = window.attributes.also { it.layoutInDisplayCutoutMode = previousCutout }
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
|
||||
private fun Activity.toggleLandscape(): Boolean {
|
||||
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
requestedOrientation = if (lock) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
else ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
return lock
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the transport offers, in two rows.
|
||||
*
|
||||
* A phone in landscape has one thumb's worth of room, so the second row is
|
||||
* icons: the transport cluster on the left, where a thumb already is, and the
|
||||
* pickers on the right. Only the seek bar gets a full row of its own.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PlayerControls(
|
||||
titleKey: String,
|
||||
host: NativeMpvHost,
|
||||
state: MpvPlaybackState,
|
||||
chapters: List<ChapterEntry>,
|
||||
tracks: List<MpvTrack>,
|
||||
hasPrevious: Boolean,
|
||||
hasNext: Boolean,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onRotate: () -> Unit,
|
||||
landscapeLocked: Boolean,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var choosing by remember { mutableStateOf<String?>(null) }
|
||||
var showChapters by remember { mutableStateOf(false) }
|
||||
var showSpeeds by remember { mutableStateOf(false) }
|
||||
// What the store says, so the dialog opens on the rate that is playing.
|
||||
var speed by remember { mutableStateOf(1.0) }
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||
.onSuccess { speed = it.speed }
|
||||
}
|
||||
val duration = state.duration.toFloat().coerceAtLeast(1f)
|
||||
val density = LocalDensity.current
|
||||
val roomForVolume = with(density) {
|
||||
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
// Where the thumb is while a finger is on it. A seek per touch-move
|
||||
// aborts every outstanding prefetch, so a one-second drag used to issue
|
||||
// dozens of seeks and throw away every in-flight block — and the thumb
|
||||
// snapped backwards under the finger, because it was drawn from a
|
||||
// position that only updates four times a second.
|
||||
var scrubbing by remember { mutableStateOf<Float?>(null) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
formatTime(scrubbing?.toDouble() ?: state.position),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Box(Modifier.weight(1f)) {
|
||||
Slider(
|
||||
value = scrubbing ?: state.position.toFloat().coerceIn(0f, duration),
|
||||
onValueChange = { scrubbing = it },
|
||||
onValueChangeFinished = {
|
||||
scrubbing?.let { host.seek(it.toDouble()) }
|
||||
scrubbing = null
|
||||
},
|
||||
valueRange = 0f..duration,
|
||||
track = { AccentTrack(it) },
|
||||
)
|
||||
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
|
||||
}
|
||||
Text(formatTime(state.duration), color = Color.White, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CompactIcon(Icons.Default.SkipPrevious, "Previous episode", enabled = hasPrevious, onClick = onPrevious)
|
||||
CompactIcon(Icons.Default.Replay10, "Back ten seconds") { host.seek(state.position - 10) }
|
||||
IconButton(onClick = { host.setPaused(!state.paused) }) {
|
||||
Icon(
|
||||
if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||
contentDescription = if (state.paused) "Play" else "Pause",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
CompactIcon(Icons.Default.Forward30, "Forward thirty seconds") { host.seek(state.position + 30) }
|
||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chapters.size > 1) {
|
||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||
}
|
||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||
CompactIcon(
|
||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
"Mute",
|
||||
) {
|
||||
host.setMuted(!state.muted)
|
||||
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
|
||||
}
|
||||
// The app's own volume, which is not the device's: a file
|
||||
// mastered quiet is turned up here and stays up for the next
|
||||
// one. Only where there is room for it — a phone in portrait
|
||||
// has the system volume keys and no width to spare.
|
||||
if (roomForVolume) {
|
||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||
Slider(
|
||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||
onValueChangeFinished = {
|
||||
val chosen = dragging?.toDouble()
|
||||
dragging = null
|
||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||
},
|
||||
valueRange = 0f..MAX_VOLUME,
|
||||
modifier = Modifier.width(96.dp),
|
||||
)
|
||||
}
|
||||
CompactIcon(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSpeeds) {
|
||||
SpeedChooser(
|
||||
current = speed,
|
||||
onDismiss = { showSpeeds = false },
|
||||
onSelect = { chosen ->
|
||||
speed = chosen
|
||||
host.setSpeed(chosen)
|
||||
showSpeeds = false
|
||||
scope.launch(Dispatchers.IO) { rememberSpeed(chosen) }
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showChapters) {
|
||||
ChapterChooser(
|
||||
chapters = chapters,
|
||||
position = state.position,
|
||||
onDismiss = { showChapters = false },
|
||||
onSelect = { host.seek(it.start); showChapters = false },
|
||||
)
|
||||
}
|
||||
choosing?.let { type ->
|
||||
TrackChooser(
|
||||
type = type,
|
||||
tracks = tracks.filter { it.type == type },
|
||||
onDismiss = { choosing = null },
|
||||
onSelect = { selected ->
|
||||
host.selectTrack(selected)
|
||||
choosing = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// No per-title choice yet: start from what plays everywhere
|
||||
// else, so choosing an audio track does not silently drop
|
||||
// the subtitle preference this episode started with.
|
||||
val global = engine.playbackPrefs()
|
||||
val old = engine.titleTrackPreferences(titleKey) ?: TrackPreferencesRecord(
|
||||
audioLanguage = global.audioLanguage,
|
||||
subtitleLanguage = global.subtitleLanguage,
|
||||
subtitles = global.subtitles,
|
||||
)
|
||||
engine.setTitleTrackPreferences(
|
||||
titleKey,
|
||||
if (type == "audio") {
|
||||
old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
|
||||
} else {
|
||||
// Turning subtitles off keeps the language it was
|
||||
// off *from*, so turning them back on does not have
|
||||
// to be told the language again — as on desktop.
|
||||
old.copy(
|
||||
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank)
|
||||
?: old.subtitleLanguage,
|
||||
subtitles = selected != null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a volume or a mute, leaving whichever was not given as it was.
|
||||
*
|
||||
* Written when the thumb is released rather than while it moves: a drag emits
|
||||
* dozens of values a second and every one of them would be a file rewrite. A
|
||||
* failure is dropped — the volume is already applied, and the cost of not
|
||||
* storing it is that the next launch starts where the last one was stored.
|
||||
*/
|
||||
private suspend fun rememberVolume(volume: Double? = null, muted: Boolean? = null) {
|
||||
runCatching {
|
||||
val engine = NativeRuntime.engine()
|
||||
val prefs = engine.playbackPrefs()
|
||||
engine.setPlaybackPrefs(prefs.copy(volume = volume ?: prefs.volume, muted = muted ?: prefs.muted))
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a playback rate, so the next episode starts at it. */
|
||||
private suspend fun rememberSpeed(speed: Double) {
|
||||
runCatching {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.setPlaybackPrefs(engine.playbackPrefs().copy(speed = speed))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rates worth offering.
|
||||
*
|
||||
* A short list rather than a slider: the useful rates are a handful of ratios,
|
||||
* and a slider on a phone lands on 1.07 as often as on 1.0.
|
||||
*/
|
||||
private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
|
||||
|
||||
@Composable
|
||||
private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Double) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Playback speed") },
|
||||
text = {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SPEEDS.forEach { rate ->
|
||||
EdgedButton(
|
||||
onClick = { onSelect(rate) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
(if (kotlin.math.abs(rate - current) < 0.001) "✓ " else "") +
|
||||
if (rate == 1.0) "Normal" else "${rate}×",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Where each chapter begins, ticked onto the seek bar. */
|
||||
@Composable
|
||||
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
|
||||
if (chapters.size < 2 || duration <= 0.0) return
|
||||
Canvas(modifier.padding(horizontal = 10.dp)) {
|
||||
val width = 2.dp.toPx()
|
||||
val height = 8.dp.toPx()
|
||||
chapters.drop(1).forEach { chapter ->
|
||||
val fraction = (chapter.start / duration).coerceIn(0.0, 1.0).toFloat()
|
||||
drawRect(
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
topLeft = Offset(fraction * size.width - width / 2, (size.height - height) / 2),
|
||||
size = Size(width, height),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChapterChooser(
|
||||
chapters: List<ChapterEntry>,
|
||||
position: Double,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (ChapterEntry) -> Unit,
|
||||
) {
|
||||
val current = chapters.indexOfLast { position + 0.001 >= it.start }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Chapters") },
|
||||
text = {
|
||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
||||
items(chapters, key = ChapterEntry::index) { chapter ->
|
||||
EdgedButton(
|
||||
onClick = { onSelect(chapter) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
(if (chapters.getOrNull(current) === chapter) "✓ " else "") +
|
||||
"${formatTime(chapter.start)} ${chapter.label}",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown into the next episode, shown once the credits run starts.
|
||||
*
|
||||
* "Watch till the end" holds for the rest of the file: someone who wants the
|
||||
* post-credits scene should have to say so once, not once every ten seconds.
|
||||
*/
|
||||
@Composable
|
||||
private fun UpNextCard(
|
||||
next: EpisodeRecord?,
|
||||
visible: Boolean,
|
||||
onPlayNow: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var held by remember(next?.linkId) { mutableStateOf(false) }
|
||||
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||
val upNext = next
|
||||
val showing = visible && upNext != null && !held
|
||||
LaunchedEffect(showing) {
|
||||
if (!showing) return@LaunchedEffect
|
||||
remaining = UP_NEXT_SECONDS
|
||||
while (remaining > 0) {
|
||||
delay(1_000)
|
||||
remaining -= 1
|
||||
}
|
||||
onPlayNow()
|
||||
}
|
||||
// `showing` already implies a non-null episode, and the compiler knows it.
|
||||
if (!showing) return
|
||||
Column(
|
||||
modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Up next in ${remaining}s",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Text(
|
||||
upNext.label,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
|
||||
AccentButton(onClick = onPlayNow) { Text("Play now") }
|
||||
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackChooser(
|
||||
type: String,
|
||||
tracks: List<MpvTrack>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (MpvTrack?) -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
|
||||
text = {
|
||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
|
||||
if (type == "sub") {
|
||||
item {
|
||||
EdgedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
|
||||
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
|
||||
}
|
||||
}
|
||||
}
|
||||
items(tracks, key = MpvTrack::id) { track ->
|
||||
EdgedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
|
||||
Text(if (track.selected) "✓ ${track.label}" else track.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactIcon(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = description,
|
||||
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ((Double) -> Unit).asFloat(): (Float) -> Unit = { this(it.toDouble()) }
|
||||
|
||||
/** How much of the episode is left, or nothing before the duration is known. */
|
||||
private fun remaining(state: MpvPlaybackState): String? = (state.duration - state.position)
|
||||
.takeIf { state.duration > 0.0 && it >= 1.0 }
|
||||
?.let { "${formatTime(it)} left" }
|
||||
|
||||
private fun formatTime(seconds: Double): String {
|
||||
val total = seconds.coerceAtLeast(0.0).roundToInt()
|
||||
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,102 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidStream
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
|
||||
class RustStreamSession(
|
||||
private val episode: EpisodeRecord,
|
||||
) {
|
||||
private var engine: AndroidEngine? = null
|
||||
private var stream: AndroidStream? = null
|
||||
private var handle: ULong? = null
|
||||
private var streamSize: ULong? = null
|
||||
|
||||
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
|
||||
val openedEngine = NativeRuntime.engine()
|
||||
engine = openedEngine
|
||||
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
|
||||
streamSize = it.size()
|
||||
handle = it.nativeHandle()
|
||||
}
|
||||
this@RustStreamSession
|
||||
}
|
||||
|
||||
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
|
||||
|
||||
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
|
||||
|
||||
fun releaseNative(host: LibmpvHost) {
|
||||
handle?.let(host::releaseNativeStream)
|
||||
handle = null
|
||||
streamSize = null
|
||||
stream = null
|
||||
}
|
||||
|
||||
/** The native player now owns the published token until stop/end/close. */
|
||||
fun transferToPlayer() {
|
||||
handle = null
|
||||
streamSize = null
|
||||
stream = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the episode's stream back to Rust, from a scope of this session's own.
|
||||
*
|
||||
* Deliberately not the caller's: the one moment this has to run is when the
|
||||
* player leaves the composition, which is the same moment a
|
||||
* `rememberCoroutineScope` is cancelled — so a release launched there is
|
||||
* cancelled before it starts, and the stream is held for the life of the
|
||||
* process. Once per episode played, that is every episode.
|
||||
*/
|
||||
fun close(host: LibmpvHost) {
|
||||
releaseNative(host)
|
||||
sessions.launch {
|
||||
runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
|
||||
engine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where stream teardown runs: process-lifetime, like the engine it releases to,
|
||||
* and never cancelled by a screen going away.
|
||||
*/
|
||||
private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
|
||||
interface LibmpvHost {
|
||||
fun attachSurface(surface: Surface)
|
||||
fun detachSurface()
|
||||
|
||||
/**
|
||||
* Consumes the opaque handle through pstr_android_stream_{read,size}.
|
||||
*
|
||||
* [media] names the episode being loaded. One host serves every episode in
|
||||
* turn, so its readings have to say which one they describe — see
|
||||
* [MpvPlaybackState.media].
|
||||
*/
|
||||
suspend fun play(
|
||||
media: String,
|
||||
nativeHandle: ULong,
|
||||
size: ULong,
|
||||
startPosition: Double = 0.0,
|
||||
audioLanguage: String? = null,
|
||||
subtitleLanguage: String? = null,
|
||||
subtitles: Boolean = true,
|
||||
)
|
||||
|
||||
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||
fun releaseNativeStream(nativeHandle: ULong)
|
||||
}
|
||||
|
||||
/** How an episode is named in watch state, work names and player readings. */
|
||||
fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.narl.protonstream.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The settings that are Android's alone.
|
||||
*
|
||||
* Playback preferences — volume, languages, autoplay, auto-skip — deliberately
|
||||
* do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
|
||||
* there is one definition of them rather than one per front end.
|
||||
*/
|
||||
class SettingsStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var wifiOnly: Boolean
|
||||
get() = preferences.getBoolean(KEY_WIFI_ONLY, true)
|
||||
set(value) { preferences.edit().putBoolean(KEY_WIFI_ONLY, value).apply() }
|
||||
|
||||
var backgroundAudio: Boolean
|
||||
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
||||
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
||||
|
||||
private companion object {
|
||||
const val NAME = "proton_stream_settings"
|
||||
const val KEY_WIFI_ONLY = "wifi_only"
|
||||
const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.DownloadStateStore
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.MatchSummary
|
||||
import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
val query: String = "",
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
val shares: List<ShareRecord> = emptyList(),
|
||||
val offline: List<OfflineRecord> = emptyList(),
|
||||
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
||||
enabled = false,
|
||||
provider = MetadataProvider.ANI_LIST,
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||
private val appContext = context.applicationContext
|
||||
private val mutableState = MutableStateFlow(AppUiState())
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
mutableState.update {
|
||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
viewModelScope.launch {
|
||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { reload() }
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
mutableState.update { it.copy(query = query) }
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recrawl one share rather than the whole library.
|
||||
*
|
||||
* A viewer who has just added files to one link should not have to pay for
|
||||
* a walk of every other one — and a share whose link has expired makes a
|
||||
* whole-library refresh fail, which used to leave no way to refresh the
|
||||
* shares that still work.
|
||||
*/
|
||||
fun refreshShare(id: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run enrichment again.
|
||||
*
|
||||
* With [force] every title is looked up afresh, including ones that already
|
||||
* matched — the way out of a library where a provider's early answers were
|
||||
* wrong, or where a match was made against a provider that has since been
|
||||
* changed.
|
||||
*/
|
||||
fun matchTitles(force: Boolean) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun addShare(name: String, url: String, password: String?) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
engine.crawl(null)
|
||||
}
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-supply the link behind a share whose stored secret cannot be read.
|
||||
*
|
||||
* The Keystore key is invalidated by a lockscreen change or a device
|
||||
* restore, and after that every open of every share fails — including the
|
||||
* read inside "remove this share", so the obvious way out is closed too.
|
||||
* Re-entering the link rewrites the secret and leaves the catalog and the
|
||||
* offline files alone.
|
||||
*/
|
||||
fun repairShare(id: String, url: String, password: String?) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
|
||||
engine.crawl(id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeShare(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Wait for WorkManager cancellation before Rust removes
|
||||
// catalog/files. Rust also rejects any late publication.
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||
val downloads = DownloadStateStore(appContext)
|
||||
val engine = NativeRuntime.engine()
|
||||
downloads.records().filter { it.shareId == id }.forEach {
|
||||
engine.removeOfflineEpisode(it.shareId, it.linkId)
|
||||
}
|
||||
downloads.removeShare(id)
|
||||
engine.removeShare(id)
|
||||
}
|
||||
}
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissMessage() = mutableState.update { it.copy(message = null) }
|
||||
|
||||
fun reportError(error: Throwable) {
|
||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||
}
|
||||
|
||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
if (enabled) engine.matchTitles(false) else null
|
||||
}
|
||||
}.onSuccess { summary ->
|
||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadAfterMetadataChange() = reload()
|
||||
|
||||
/**
|
||||
* What an enrichment pass did, in one line.
|
||||
*
|
||||
* Failures are reported even when most titles matched: a run that quietly
|
||||
* dropped a third of the library is the case this counting exists for.
|
||||
*/
|
||||
private fun describe(summary: MatchSummary): String {
|
||||
if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
|
||||
return "Everything is already matched"
|
||||
}
|
||||
val parts = mutableListOf("${summary.matched} matched")
|
||||
if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
|
||||
if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
|
||||
if (summary.failed > 0u) parts += "${summary.failed} failed"
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
fun removeOffline(file: OfflineRecord) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().removeOfflineEpisode(file.shareId, file.linkId) }
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseDownload(download: RetainedDownload) = DownloadCoordinator.pause(appContext, download)
|
||||
|
||||
fun resumeDownload(download: RetainedDownload) = DownloadCoordinator.resume(appContext, download)
|
||||
|
||||
fun deletePartial(download: RetainedDownload) {
|
||||
viewModelScope.launch {
|
||||
DownloadCoordinator.cancel(appContext, download)
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
workManager.cancelUniqueWork(
|
||||
DownloadCoordinator.workName(download.shareId, download.linkId),
|
||||
).result.get()
|
||||
NativeRuntime.engine().removeOfflineEpisode(download.shareId, download.linkId)
|
||||
DownloadStateStore(appContext).remove(download.shareId, download.linkId)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist where an episode was left.
|
||||
*
|
||||
* The player calls this while it is still up and once more as it leaves, so
|
||||
* it runs on the ViewModel's scope rather than the screen's — a save issued
|
||||
* on the way out must not be cancelled by the screen going away.
|
||||
*/
|
||||
fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
position.coerceIn(0.0, duration),
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an episode seen, or unseen, without playing it.
|
||||
*
|
||||
* Unwatching rewinds, matching the desktop client: an episode marked unseen
|
||||
* that kept its position would come straight back as "resume at 19:04",
|
||||
* which is not what the viewer asked for. Marking one seen puts the position
|
||||
* at its duration where one is known, so the progress bar agrees with the
|
||||
* tick.
|
||||
*/
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
||||
engine.saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
if (watched) duration ?: 0.0 else 0.0,
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllOffline() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
|
||||
NativeRuntime.engine().removeAllOffline()
|
||||
DownloadStateStore(appContext).clear()
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBlockCache() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
|
||||
}.onSuccess { reclaimed ->
|
||||
mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
engine.pruneOfflineFiles()
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadLibrary(query: String) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
|
||||
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
|
||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
|
||||
}
|
||||
}
|
||||
|
||||
private data class Reloaded(
|
||||
val shares: List<ShareRecord>,
|
||||
val titles: List<TitleRecord>,
|
||||
val offline: List<OfflineRecord>,
|
||||
val metadataSettings: MetadataSettingsRecord,
|
||||
val storage: StorageUsageRecord,
|
||||
)
|
||||
|
||||
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
|
||||
val work = getWorkInfosByTagLiveData(tag)
|
||||
val observer = Observer<List<WorkInfo>> { trySend(it).isSuccess }
|
||||
work.observeForever(observer)
|
||||
awaitClose { work.removeObserver(observer) }
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,250 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* One file to pull Proton's own thumbnail from when no provider poster exists.
|
||||
*
|
||||
* Proton renders a thumbnail per file, so any episode of a show is a frame of
|
||||
* the right show — the desktop client picks the first for the same reason.
|
||||
*/
|
||||
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
|
||||
val key: String get() = "$shareId/$linkId"
|
||||
}
|
||||
|
||||
/**
|
||||
* What the first episode of a title offers as a poster.
|
||||
*
|
||||
* Metadata lookups are off by default, so for a freshly crawled library this is
|
||||
* the only artwork there is.
|
||||
*/
|
||||
internal val TitleRecord.thumbnailSource: ThumbnailSource?
|
||||
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
|
||||
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
|
||||
|
||||
/** What one episode offers as a still. */
|
||||
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
||||
get() = ThumbnailSource(shareId, volumeId, linkId)
|
||||
|
||||
/**
|
||||
* HTTPS-only artwork loader backed by the app-private cache directory.
|
||||
*
|
||||
* [fallback] is used only when there is no provider artwork or it fails to
|
||||
* load, which — with metadata off — is the common case rather than the rare
|
||||
* one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteArtwork(
|
||||
url: String?,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: ThumbnailSource? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, fallback) {
|
||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
||||
}
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
||||
image?.let {
|
||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||
*
|
||||
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
|
||||
* it from a service with no composition of its own — but it wants exactly this
|
||||
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||
* shows costs no second download.
|
||||
*/
|
||||
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
||||
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val uri = URI(requested)
|
||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
val cached = directory.resolve("$digest.img")
|
||||
val fresh = !cached.isFile
|
||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
|
||||
if (fresh) {
|
||||
// Written only once it has decoded. Caching the bytes first is
|
||||
// what turns one hostile poster into a crash on every launch
|
||||
// that survives until app data is cleared.
|
||||
val temporary = directory.resolve("$digest.part")
|
||||
temporary.writeBytes(bytes)
|
||||
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
|
||||
prune(directory)
|
||||
} else {
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
}
|
||||
bitmap
|
||||
}.getOrNull()?.also { decoded.put(requested, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proton's own thumbnail for one file, decoded.
|
||||
*
|
||||
* The bridge holds the disk cache, so what is worth keeping here is the decoded
|
||||
* bitmap and the *absence* of one: a file with no thumbnail is common, and
|
||||
* without remembering that, every scroll back over the grid pays a round trip
|
||||
* per tile to be told so again. A failed call is not remembered — an unreachable
|
||||
* share or a session that has to be reopened is transient, and the next
|
||||
* composition should try again.
|
||||
*/
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
|
||||
val file = source ?: return null
|
||||
val key = file.key
|
||||
decoded.get(key)?.let { return it }
|
||||
if (key in missing) return null
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||
}
|
||||
val bytes = fetched.getOrElse { return null }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
|
||||
if (bitmap == null) {
|
||||
missing.add(key)
|
||||
return null
|
||||
}
|
||||
decoded.put(key, bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun download(uri: URI): ByteArray {
|
||||
val connection = uri.toURL().openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 10_000
|
||||
connection.instanceFollowRedirects = true
|
||||
val status = connection.responseCode
|
||||
require(status in 200..299) { "Artwork request failed ($status)" }
|
||||
require(connection.url.protocol.equals("https", ignoreCase = true)) {
|
||||
"Artwork redirect must use HTTPS"
|
||||
}
|
||||
return connection.inputStream.use(::readBounded)
|
||||
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode within a bound the display can actually use.
|
||||
*
|
||||
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||
* costs no allocation and settles a sample size before anything is committed.
|
||||
*/
|
||||
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (longest <= 0) return null
|
||||
var sample = 1
|
||||
while (longest / sample > MAX_ART_EDGE) sample *= 2
|
||||
return BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||
*
|
||||
* `cacheDir` is reclaimable by the system, but only under storage pressure and
|
||||
* only wholesale — a poster cache that grows with every title ever browsed is
|
||||
* still the app's own doing.
|
||||
*/
|
||||
private fun prune(directory: File) {
|
||||
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
|
||||
var total = files.sumOf(File::length)
|
||||
for (file in files) {
|
||||
if (total <= MAX_ART_CACHE_BYTES) return
|
||||
total -= file.length()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files known to have no Proton thumbnail, so the answer is paid for once.
|
||||
*
|
||||
* Deliberately not persisted: a re-upload can add one, and the cost of being
|
||||
* wrong for a session is a placeholder that a restart clears.
|
||||
*/
|
||||
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
|
||||
|
||||
/**
|
||||
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
|
||||
*
|
||||
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
|
||||
* cannot collide, since one is always an `https:` URL.
|
||||
*
|
||||
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
|
||||
* recreates items as they leave the viewport — without this, every scroll back
|
||||
* is a full-resolution decode per item.
|
||||
*/
|
||||
private val decoded = object : LruCache<String, Bitmap>(
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
|
||||
) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
private const val MAX_ART_BYTES = 12 * 1024 * 1024
|
||||
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
|
||||
|
||||
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
|
||||
private const val MAX_ART_EDGE = 1_024
|
||||
|
||||
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
|
||||
private fun readBounded(input: InputStream): ByteArray {
|
||||
val output = ByteArrayOutputStream(MAX_ART_BYTES.coerceAtMost(64 * 1024))
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var total = 0
|
||||
while (total <= MAX_ART_BYTES) {
|
||||
val requested = minOf(buffer.size, MAX_ART_BYTES + 1 - total)
|
||||
val count = input.read(buffer, 0, requested)
|
||||
if (count < 0) break
|
||||
if (count == 0) continue
|
||||
output.write(buffer, 0, count)
|
||||
total += count
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.NavigationRailItemDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||
import androidx.compose.material3.SliderState
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The accent, as a gradient when there is one to draw.
|
||||
*
|
||||
* The desktop paints the accent as a two-hue ramp on everything that is a
|
||||
* control — the seek bar, the primary buttons, the tile progress bar — and the
|
||||
* viewer's "paint the accent as a gradient" toggle is what turns it off. That
|
||||
* toggle was already stored, already offered on this screen, and drawn nowhere
|
||||
* on Android, so the same stored setting meant two different things depending
|
||||
* on which client read it.
|
||||
*
|
||||
* Nothing here reads the toggle. It does not need to: when gradients are off,
|
||||
* `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses
|
||||
* to a flat fill on its own. `primary` and `secondary` are those two colours —
|
||||
* see [schemeOf] — which is why this asks Material for them rather than
|
||||
* reaching past it for the palette.
|
||||
*/
|
||||
@Composable
|
||||
internal fun accentBrush(): Brush {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
return remember(scheme.primary, scheme.secondary) {
|
||||
Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary button: the accent ramp, at the shared corner radius.
|
||||
*
|
||||
* Material's filled button has its own shape token (`CornerFull`) that does not
|
||||
* read `MaterialTheme.shapes`, so the radius has to be handed to it — which is
|
||||
* the whole reason the plain buttons below exist as well. They add nothing but
|
||||
* that.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccentButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.small
|
||||
Button(
|
||||
onClick = onClick,
|
||||
// Only while it is live: a disabled button keeps Material's flat
|
||||
// disabled container, because a greyed control that still wears the
|
||||
// one strong colour in the window reads as pressable.
|
||||
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TonalButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun EdgedButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun QuietButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far along something is, drawn in the accent.
|
||||
*
|
||||
* Hand-drawn rather than `LinearProgressIndicator` because that one takes a
|
||||
* `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop
|
||||
* indicator either side of the bar — detail at a size where the desktop draws a
|
||||
* plain bar. [fill] is a parameter for the one bar that is deliberately not the
|
||||
* accent: a download in flight sits directly under the watch-progress bar of
|
||||
* the same row, and two ramps in the same colour stacked two pixels apart say
|
||||
* nothing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccentProgress(
|
||||
progress: () -> Float,
|
||||
modifier: Modifier = Modifier,
|
||||
fill: Brush? = null,
|
||||
) {
|
||||
val brush = fill ?: accentBrush()
|
||||
val track = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) {
|
||||
val radius = CornerRadius(size.height / 2)
|
||||
drawRoundRect(color = track, cornerRadius = radius)
|
||||
val done = size.width * progress().coerceIn(0f, 1f)
|
||||
// Below one bar's width there is nothing to round, and a rounded rect
|
||||
// narrower than its own corners draws as a wrong-looking pill.
|
||||
if (done >= size.height) {
|
||||
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The seek bar's track, in the accent ramp.
|
||||
*
|
||||
* Passed to `Slider`'s `track` slot, so the thumb, the gestures and the
|
||||
* accessibility semantics stay Material's — this replaces the two rectangles it
|
||||
* would have drawn and nothing else.
|
||||
*
|
||||
* The spent side is the accent and the rest is white at a third: the seek bar
|
||||
* is the one accent surface that sits over arbitrary film footage, where a
|
||||
* palette colour for the *unplayed* remainder would be a coloured line over a
|
||||
* picture rather than the absence of one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
|
||||
val brush = accentBrush()
|
||||
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
||||
val radius = CornerRadius(size.height / 2)
|
||||
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
||||
val done = size.width * state.coercedValueAsFraction
|
||||
if (done >= size.height) {
|
||||
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation pill, in the accent.
|
||||
*
|
||||
* Solid rather than a ramp, and this is the one place the two clients cannot
|
||||
* agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`,
|
||||
* with no slot to draw into, so a gradient here would mean reimplementing the
|
||||
* bar. The accent itself is still the right answer — the desktop's rule is that
|
||||
* the accent marks the thing you are on — and it is nearer to that than
|
||||
* `secondaryContainer`, which is the accent taken most of the way back to the
|
||||
* page.
|
||||
*/
|
||||
@Composable
|
||||
internal fun accentNavigationColors(): NavigationSuiteItemColors {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
return NavigationSuiteDefaults.itemColors(
|
||||
navigationBarItemColors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = scheme.onPrimary,
|
||||
selectedTextColor = scheme.onSurface,
|
||||
indicatorColor = scheme.primary,
|
||||
),
|
||||
navigationRailItemColors = NavigationRailItemDefaults.colors(
|
||||
selectedIconColor = scheme.onPrimary,
|
||||
selectedTextColor = scheme.onSurface,
|
||||
indicatorColor = scheme.primary,
|
||||
),
|
||||
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = scheme.primary,
|
||||
selectedIconColor = scheme.onPrimary,
|
||||
selectedTextColor = scheme.onPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A solid fill, for the callers of [AccentProgress] that want one colour. */
|
||||
internal fun solid(color: Color): Brush = SolidColor(color)
|
||||
|
||||
/** Thin enough to sit under a row without becoming part of it. */
|
||||
private const val PROGRESS_HEIGHT = 4f
|
||||
|
||||
/** The seek bar is dragged with a finger, so it is the thicker of the two. */
|
||||
private const val TRACK_HEIGHT = 6f
|
||||
@@ -0,0 +1,36 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* One corner radius, which is the desktop client's corner radius.
|
||||
*
|
||||
* `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab
|
||||
* pill, the progress bars — and only a badge over artwork is tighter, at 4.
|
||||
* Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen
|
||||
* puts a 28dp bottom sheet next to a 12dp card next to a fully-round button,
|
||||
* and none of those is the shape the other client draws.
|
||||
*
|
||||
* Flat rather than a ramp, therefore, with the two ends kept for the two cases
|
||||
* where a single radius is wrong: `extraSmall` for something small enough that
|
||||
* 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of
|
||||
* the screen and need to read as a lifted sheet rather than a seam.
|
||||
*/
|
||||
internal val ProtonShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(CORNER_TIGHT.dp),
|
||||
small = RoundedCornerShape(CORNER.dp),
|
||||
medium = RoundedCornerShape(CORNER.dp),
|
||||
large = RoundedCornerShape(CORNER.dp),
|
||||
extraLarge = RoundedCornerShape(CORNER_SHEET.dp),
|
||||
)
|
||||
|
||||
/** The radius `pstr-app` draws everything at. */
|
||||
internal const val CORNER = 8f
|
||||
|
||||
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
|
||||
internal const val CORNER_TIGHT = 4f
|
||||
|
||||
/** A bottom sheet, which has no desktop counterpart. */
|
||||
internal const val CORNER_SHEET = 16f
|
||||
@@ -0,0 +1,216 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/**
|
||||
* The palette in force, shared across every composition.
|
||||
*
|
||||
* The choice is stored by Rust and resolved by Rust — the same flavours, the
|
||||
* same accent pairings and the same contrast rule the desktop client uses — so
|
||||
* what lives here is only the resolved answer and a way to replace it when the
|
||||
* viewer picks something else.
|
||||
*/
|
||||
object AppearanceState {
|
||||
private val current = MutableStateFlow<PaletteRecord?>(null)
|
||||
val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
|
||||
|
||||
/**
|
||||
* The palette read before the first frame, from
|
||||
* [io.narl.protonstream.native.NativeRuntime.storedPalette].
|
||||
*
|
||||
* Separate from [load] because it runs on the main thread during
|
||||
* `onCreate`: it is the only read that is early enough to decide what the
|
||||
* first composition is painted in, and it is cheap enough to be allowed
|
||||
* there.
|
||||
*/
|
||||
fun seed(palette: PaletteRecord) {
|
||||
current.value = palette
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored choice through the engine. Safe to call repeatedly; the
|
||||
* last read wins.
|
||||
*
|
||||
* A no-op once [seed] has run — it resolves the same file through the same
|
||||
* Rust, so the only thing a second read can do is build the engine earlier
|
||||
* than the first screen needed it.
|
||||
*/
|
||||
suspend fun load() {
|
||||
if (current.value != null) return
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Repaint immediately, before the write that stores the choice lands. */
|
||||
fun apply(palette: PaletteRecord) {
|
||||
current.value = palette
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
||||
val palette by AppearanceState.palette.collectAsState()
|
||||
// Before the first read, and if it fails, the palette this app shipped
|
||||
// with — a theme file that cannot be read must never be a reason the
|
||||
// library does not open.
|
||||
LaunchedEffect(Unit) { AppearanceState.load() }
|
||||
MaterialTheme(
|
||||
colorScheme = schemeOf(palette ?: ProtonFallback),
|
||||
// The other two thirds of what the clients share. Colour was already
|
||||
// one resolved answer for both; type and shape were Material's defaults
|
||||
// here and named constants there, which is most of why the two read as
|
||||
// different apps even where the colours agreed.
|
||||
typography = ProtonTypography,
|
||||
shapes = ProtonShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved palette as Material's roles — **all** of them.
|
||||
*
|
||||
* Every role Material names has to be answered here, because a role left out
|
||||
* does not fall back to something neutral: it keeps the value
|
||||
* `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own
|
||||
* baseline purple-and-grey and belongs to no flavour this app ships.
|
||||
*
|
||||
* That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with
|
||||
* (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in
|
||||
* the library grid, every episode row, every share and every download was drawn
|
||||
* in baseline neutral on top of a flavoured page. `inverseSurface` is the
|
||||
* snackbar — the app's only error channel — and `scrim` is the dim behind every
|
||||
* dialog.
|
||||
*
|
||||
* Material names more roles than a palette has colours, so several are answered
|
||||
* by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the
|
||||
* blend behind them is the shared one. `SchemeRolesTest` asserts that nothing
|
||||
* here is left at its baseline.
|
||||
*/
|
||||
internal fun schemeOf(palette: PaletteRecord): ColorScheme {
|
||||
val base = if (palette.light) lightColorScheme() else darkColorScheme()
|
||||
return base.copy(
|
||||
primary = color(palette.accent),
|
||||
onPrimary = color(palette.onAccent),
|
||||
primaryContainer = color(palette.accentDim),
|
||||
onPrimaryContainer = color(palette.text),
|
||||
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
|
||||
// is the accent taken towards the page, so it inverts with the rest.
|
||||
inversePrimary = color(palette.accentDim),
|
||||
secondary = color(palette.accentAlt),
|
||||
onSecondary = color(palette.onAccent),
|
||||
// The accent taken back towards the page, not the card's hover shade.
|
||||
// `secondaryContainer` is what fills every tonal button and the
|
||||
// navigation bar's selected pill, and `cardHover` sits one step off
|
||||
// `card` — which on a dark flavour made all of them dark grey shapes on
|
||||
// a dark grey row, readable as text but not as controls.
|
||||
secondaryContainer = color(palette.accentDim),
|
||||
onSecondaryContainer = color(palette.text),
|
||||
// The palette has two hues, not three. Tertiary takes the second one —
|
||||
// the gradient partner — rather than inventing a third that no flavour
|
||||
// chose.
|
||||
tertiary = color(palette.accentAlt),
|
||||
onTertiary = color(palette.onAccent),
|
||||
tertiaryContainer = color(palette.accentDim),
|
||||
onTertiaryContainer = color(palette.text),
|
||||
background = color(palette.background),
|
||||
onBackground = color(palette.text),
|
||||
surface = color(palette.surface),
|
||||
onSurface = color(palette.text),
|
||||
surfaceVariant = color(palette.card),
|
||||
onSurfaceVariant = color(palette.muted),
|
||||
// No elevation tint. Material tints a raised surface with `primary`,
|
||||
// and the desktop draws no such tint at all — its rule is that the
|
||||
// accent is the only strong colour in the window and everything wearing
|
||||
// it is something to click. A card that is faintly accent-coloured
|
||||
// because it happens to be raised breaks that.
|
||||
surfaceTint = Color.Transparent,
|
||||
// Genuinely inverted, both ways round: on a dark flavour this is a pale
|
||||
// snackbar with dark ink, on Latte a dark one with pale ink.
|
||||
inverseSurface = color(palette.text),
|
||||
inverseOnSurface = color(palette.background),
|
||||
error = color(palette.danger),
|
||||
onError = color(palette.background),
|
||||
errorContainer = color(palette.dangerDim),
|
||||
onErrorContainer = color(palette.text),
|
||||
// Swapped from what was here before, to match what Material draws with
|
||||
// each: `outline` is a border meant to be seen (an outlined button,
|
||||
// a swatch that is not selected), `outlineVariant` is a divider meant
|
||||
// to be barely there. `border` is the fainter of the two.
|
||||
outline = color(palette.muted),
|
||||
outlineVariant = color(palette.border),
|
||||
// Black in both of Material's own schemes, and for the same reason: a
|
||||
// scrim is not a colour, it is an absence of light.
|
||||
scrim = Color.Black,
|
||||
surfaceBright = color(palette.elevated),
|
||||
surfaceDim = color(palette.background),
|
||||
// The containment ladder, in the order the palette already runs: the
|
||||
// deepest colour in the flavour at the bottom, `elevated` — one step
|
||||
// past the card's hover shade — at the top.
|
||||
surfaceContainerLowest = color(palette.sunken),
|
||||
surfaceContainerLow = color(palette.surface),
|
||||
surfaceContainer = color(palette.card),
|
||||
surfaceContainerHigh = color(palette.cardHover),
|
||||
surfaceContainerHighest = color(palette.elevated),
|
||||
// The fixed roles are Material's "the same in light and dark", which a
|
||||
// per-flavour palette has no equivalent of. They take the accent pair
|
||||
// rather than a baseline hue, so a component that reaches for one still
|
||||
// lands inside the flavour.
|
||||
primaryFixed = color(palette.accentDim),
|
||||
primaryFixedDim = color(palette.accent),
|
||||
onPrimaryFixed = color(palette.text),
|
||||
onPrimaryFixedVariant = color(palette.muted),
|
||||
secondaryFixed = color(palette.accentDim),
|
||||
secondaryFixedDim = color(palette.accentAlt),
|
||||
onSecondaryFixed = color(palette.text),
|
||||
onSecondaryFixedVariant = color(palette.muted),
|
||||
tertiaryFixed = color(palette.accentDim),
|
||||
tertiaryFixedDim = color(palette.accentAlt),
|
||||
onTertiaryFixed = color(palette.text),
|
||||
onTertiaryFixedVariant = color(palette.muted),
|
||||
)
|
||||
}
|
||||
|
||||
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
|
||||
private fun color(argb: UInt) = Color(argb.toInt())
|
||||
|
||||
/**
|
||||
* The shipped default, spelled out for the case where Rust cannot be asked.
|
||||
*
|
||||
* A `PaletteRecord` rather than a `ColorScheme`, so that it goes through
|
||||
* [schemeOf] like any other palette and inherits every mapping above — a
|
||||
* fallback that answered only ten roles would be the same bug in a smaller
|
||||
* place. The values are `pstr_core::appearance::Palette::PROTON`.
|
||||
*/
|
||||
internal val ProtonFallback = PaletteRecord(
|
||||
background = 0xFF0E0E12u,
|
||||
surface = 0xFF17171Du,
|
||||
sunken = 0xFF0A0A0Du,
|
||||
card = 0xFF1E1E26u,
|
||||
cardHover = 0xFF2A2A35u,
|
||||
border = 0xFF262630u,
|
||||
text = 0xFFEAEAF0u,
|
||||
muted = 0xFF8E8E9Cu,
|
||||
accent = 0xFF7D4DFFu,
|
||||
accentAlt = 0xFF3F6FE0u,
|
||||
accentDim = 0xFF5333ADu,
|
||||
onAccent = 0xFFEAEAF0u,
|
||||
danger = 0xFFE05561u,
|
||||
elevated = 0xFF333340u,
|
||||
dangerDim = 0xFF7F2F37u,
|
||||
light = false,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The type ramp, which is the desktop client's type ramp.
|
||||
*
|
||||
* `pstr_app::theme::Role` names nine sizes and the desktop asks for a role
|
||||
* rather than a number at every call site. Material names fifteen styles, so
|
||||
* several of them land on the same rung — that is the point. The two clients
|
||||
* were built to share one palette; sharing one scale is the other half of why
|
||||
* they should read as one app.
|
||||
*
|
||||
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
*
|
||||
* Where a role maps is decided by what actually draws with it here, not by
|
||||
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
|
||||
* a phone's lock screen and not for anything in a library grid. `bodyLarge` is
|
||||
* what an unstyled `Text` gets, so it takes the desktop's body rung and the two
|
||||
* smaller body styles step down from there.
|
||||
*
|
||||
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||
*/
|
||||
internal val ProtonTypography = Typography(
|
||||
displayLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||
// The name of the title you opened — the desktop's display line.
|
||||
headlineLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||
headlineMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
// A dialog's heading — `AlertDialog` titles draw with this one.
|
||||
headlineSmall = role(HEADING, FontWeight.SemiBold),
|
||||
// A page heading: "Offline downloads", "Settings".
|
||||
titleLarge = role(TITLE, FontWeight.SemiBold),
|
||||
// A run of content under a heading: "Continue watching", "Playback".
|
||||
titleMedium = role(SECTION, FontWeight.Medium),
|
||||
// A row that groups the rows under it — a season header.
|
||||
titleSmall = role(SUBHEAD, FontWeight.Medium),
|
||||
bodyLarge = role(BODY, FontWeight.Normal),
|
||||
bodyMedium = role(LABEL, FontWeight.Normal),
|
||||
// Context rather than content: counts, sizes, the note under a field.
|
||||
bodySmall = role(CAPTION, FontWeight.Normal),
|
||||
// The text on a button.
|
||||
labelLarge = role(LABEL, FontWeight.Medium),
|
||||
labelMedium = role(CAPTION, FontWeight.Medium),
|
||||
// The smallest thing that stays legible.
|
||||
labelSmall = role(MICRO, FontWeight.Medium),
|
||||
)
|
||||
|
||||
internal const val DISPLAY = 26f
|
||||
internal const val TITLE = 20f
|
||||
internal const val HEADING = 18f
|
||||
internal const val SECTION = 17f
|
||||
internal const val SUBHEAD = 15f
|
||||
internal const val BODY = 14f
|
||||
internal const val LABEL = 13f
|
||||
internal const val CAPTION = 12f
|
||||
internal const val MICRO = 11f
|
||||
|
||||
/**
|
||||
* One rung as a `TextStyle`.
|
||||
*
|
||||
* Line height is derived rather than named per style: a ramp whose leading was
|
||||
* chosen nine separate times is the same problem the sizes had. 1.35 is loose
|
||||
* enough for the two-line title names in the grid and tight enough that a
|
||||
* label on a control does not float.
|
||||
*/
|
||||
private fun role(size: Float, weight: FontWeight) = TextStyle(
|
||||
fontSize = size.sp,
|
||||
lineHeight = (size * 1.35f).sp,
|
||||
fontWeight = weight,
|
||||
)
|
||||
@@ -0,0 +1,396 @@
|
||||
package org.rustls.platformverifier
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.http.X509TrustManagerExtensions
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.KeyStoreException
|
||||
import java.security.MessageDigest
|
||||
import java.security.PublicKey
|
||||
import java.security.cert.CertPathValidator
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.CertificateExpiredException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.CertificateNotYetValidException
|
||||
import java.security.cert.CertificateParsingException
|
||||
import java.security.cert.PKIXBuilderParameters
|
||||
import java.security.cert.PKIXRevocationChecker
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Date
|
||||
import java.util.EnumSet
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import javax.security.auth.x500.X500Principal
|
||||
|
||||
// If this is updated, update the Rust definition too.
|
||||
// Marked private as this is not meant to be used in Android code.
|
||||
private enum class StatusCode(val value: Int) {
|
||||
Ok(0),
|
||||
Unavailable(1),
|
||||
Expired(2),
|
||||
UnknownCert(3),
|
||||
Revoked(4),
|
||||
InvalidEncoding(5),
|
||||
InvalidExtension(6),
|
||||
}
|
||||
|
||||
// Marked private as this is not meant to be used in Android code.
|
||||
private class VerificationResult(
|
||||
status: StatusCode,
|
||||
@Suppress("unused") val message: String? = null
|
||||
) {
|
||||
@Suppress("unused")
|
||||
private val code: Int = status.value
|
||||
}
|
||||
|
||||
// NOTE: All TrustManager and certificate validation methods are not thread safe. These
|
||||
// are all guarded by Kotlin's `Synchronized` accessors to prevent undefined behavior.
|
||||
|
||||
// Only JNI and test code calls this, so unused code warnings are suppressed.
|
||||
// Internal for test code - no other Kotlin code should use this object directly.
|
||||
@Suppress("unused")
|
||||
// We want to show a difference between Kotlin-side logs and those in Rust code
|
||||
@SuppressLint("LongLogTag")
|
||||
internal object CertificateVerifier {
|
||||
private const val TAG = "rustls-platform-verifier-android"
|
||||
|
||||
private fun createTrustManager(keystore: KeyStore?): X509TrustManagerExtensions? {
|
||||
// This can never throw since the default algorithm is used.
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
|
||||
factory.init(keystore)
|
||||
|
||||
val availableTrustManagers = try {
|
||||
factory.trustManagers
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "exception thrown creating a TrustManager: $e")
|
||||
return null
|
||||
}
|
||||
|
||||
for (manager in availableTrustManagers) {
|
||||
if (manager is X509TrustManager) {
|
||||
// Kotlin ensures this can't throw at runtime since it knows that
|
||||
// it must be the correct type by now.
|
||||
return X509TrustManagerExtensions(manager)
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(TAG, "failed to find a usable trust manager")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun makeLazyTrustManager(keystore: KeyStore?): Lazy<X509TrustManagerExtensions?> {
|
||||
// Ensure the keystore is loaded. Since all of the trust managers are initialized in a
|
||||
// `Lazy`, this will only run once.
|
||||
keystore?.load(null)
|
||||
|
||||
return lazy { createTrustManager(keystore) }
|
||||
}
|
||||
|
||||
// -- Test only --
|
||||
// Ideally, all of this will be optimized out at compile time due to not being accessed
|
||||
// in release builds.
|
||||
|
||||
@get:Synchronized
|
||||
private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
|
||||
@get:Synchronized
|
||||
private var mockTrustManager: Lazy<X509TrustManagerExtensions?> =
|
||||
makeLazyTrustManager(mockKeystore)
|
||||
|
||||
@JvmStatic
|
||||
private fun addMockRoot(root: ByteArray) {
|
||||
if (!false) {
|
||||
throw Exception("attempted to add a mock root outside a test!")
|
||||
}
|
||||
|
||||
val alias = "root_${mockKeystore.size()}"
|
||||
// Throwing here is fine since test roots should always be well-formed
|
||||
val cert = certFactory.generateCertificate(ByteArrayInputStream(root))
|
||||
mockKeystore.setCertificateEntry(alias, cert)
|
||||
|
||||
reloadMockData()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun clearMockRoots() {
|
||||
// Reload to get a completely fresh internal state
|
||||
mockKeystore.load(null)
|
||||
reloadMockData()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun reloadMockData() {
|
||||
if (mockTrustManager.isInitialized()) {
|
||||
mockTrustManager = makeLazyTrustManager(mockKeystore)
|
||||
}
|
||||
}
|
||||
|
||||
// Get a list of the system's root CAs.
|
||||
// Function is public for testing only.
|
||||
@JvmStatic
|
||||
fun getSystemRootCAs(): List<X509Certificate> {
|
||||
val rootCAs = mutableListOf<X509Certificate>()
|
||||
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(systemKeystore)
|
||||
|
||||
val availableTrustManagers = try {
|
||||
factory.trustManagers
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "exception thrown creating a TrustManager: $e")
|
||||
return rootCAs
|
||||
}
|
||||
|
||||
availableTrustManagers.forEach { trustManager ->
|
||||
if (trustManager is X509TrustManager) {
|
||||
rootCAs.addAll(trustManager.acceptedIssuers)
|
||||
}
|
||||
}
|
||||
|
||||
return rootCAs
|
||||
}
|
||||
|
||||
// -- End testing requirements --
|
||||
|
||||
private val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509")
|
||||
|
||||
private var systemTrustAnchorCache = hashSetOf<Pair<X500Principal, PublicKey>>()
|
||||
|
||||
@get:Synchronized
|
||||
private var systemCertificateDirectory: File? = System.getenv("ANDROID_ROOT")?.let { rootPath ->
|
||||
File("$rootPath/etc/security/cacerts")
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
private val systemKeystore: KeyStore? = try {
|
||||
KeyStore.getInstance("AndroidCAStore")
|
||||
} catch (_: KeyStoreException) {
|
||||
null
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
private val systemTrustManager: Lazy<X509TrustManagerExtensions?> =
|
||||
makeLazyTrustManager(systemKeystore)
|
||||
|
||||
@JvmStatic
|
||||
private fun verifyCertificateChain(
|
||||
@Suppress("UNUSED_PARAMETER") context: Context,
|
||||
serverName: String,
|
||||
authMethod: String,
|
||||
allowedEkus: Array<String>,
|
||||
ocspResponse: ByteArray?,
|
||||
time: Long,
|
||||
certChain: Array<ByteArray>
|
||||
): VerificationResult {
|
||||
// Convert the array of (supposedly) DER bytes into certificates.
|
||||
val certificateChain = mutableListOf<X509Certificate>()
|
||||
certChain.forEach { certBytes ->
|
||||
val certificate = try {
|
||||
certFactory.generateCertificate(ByteArrayInputStream(certBytes))
|
||||
} catch (e: CertificateException) {
|
||||
return VerificationResult(StatusCode.InvalidEncoding)
|
||||
}
|
||||
certificateChain.add(certificate as X509Certificate)
|
||||
}
|
||||
|
||||
// Will never throw `ArrayIndexOutOfBoundsException` because `rustls`'s `ServerCertVerifier` trait
|
||||
// has a mandatory `end_entity` parameter in `verify_server_cert`.
|
||||
val endEntity = certificateChain[0]
|
||||
|
||||
// Check that the certificate is valid at the point of time provided by `rustls`.
|
||||
try {
|
||||
endEntity.checkValidity(Date(time))
|
||||
} catch (e: CertificateExpiredException) {
|
||||
return VerificationResult(StatusCode.Expired)
|
||||
} catch (e: CertificateNotYetValidException) {
|
||||
return VerificationResult(StatusCode.Expired)
|
||||
}
|
||||
|
||||
// Check that this certificate can be used in a TLS server.
|
||||
if (!verifyCertUsage(endEntity, allowedEkus)) {
|
||||
return VerificationResult(StatusCode.InvalidExtension)
|
||||
}
|
||||
|
||||
// Select the trust manager to use.
|
||||
//
|
||||
// We select them as follows:
|
||||
// - If built for release, only use the system trust manager. This should let all test-related
|
||||
// code be optimized out.
|
||||
// - If built for tests:
|
||||
// - If the mock CA store has any values, use the mock trust manager.
|
||||
// - Otherwise, use the system trust manager.
|
||||
val (trustManager, keystore) = if (!false) {
|
||||
val trustManager =
|
||||
systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable)
|
||||
Pair(trustManager, systemKeystore)
|
||||
} else {
|
||||
if (mockKeystore.size() != 0) {
|
||||
val trustManager = mockTrustManager.value!!
|
||||
Pair(trustManager, mockKeystore)
|
||||
} else {
|
||||
val trustManager =
|
||||
systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable)
|
||||
Pair(trustManager, systemKeystore)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the certificate chain is valid and correct, and nothing more.
|
||||
//
|
||||
// NOTE: This does not validate `serverName` is valid for the end-entity certificate.
|
||||
// That is handled in Rust as Android/Java do not currently provide a RFC 6125 compliant
|
||||
// hostname verifier. Additionally, even the RFC 2818 verifier is not available until API 24.
|
||||
//
|
||||
// `serverName` is only used for pinning/CT requirements.
|
||||
//
|
||||
// Returns the "the properly ordered chain used for verification as a list of X509Certificates.",
|
||||
// meaning a list from end-entity certificate to trust-anchor.
|
||||
val validChain = try {
|
||||
trustManager.checkServerTrusted(certificateChain.toTypedArray(), authMethod, serverName)
|
||||
} catch (e: CertificateException) {
|
||||
// In test configurations we may see `checkServerTrusted` fail once vendored test
|
||||
// certificates pass their expiry date. We try to avoid that by using a fixed
|
||||
// verification time when calling `endEntity.checkValidity` above, however we can't
|
||||
// fix the time for the `checkServerTrusted` call.
|
||||
//
|
||||
// To make diagnosing CI test failures easier we try to find the root cause of
|
||||
// checkServerTrusted failing, returning a different `StatusCode` as appropriate.
|
||||
if (false) {
|
||||
var rootCause: Throwable? = e
|
||||
while (rootCause?.cause != null && rootCause.cause != rootCause) {
|
||||
rootCause = rootCause.cause
|
||||
}
|
||||
return when (rootCause) {
|
||||
is CertificateExpiredException, is CertificateNotYetValidException -> VerificationResult(
|
||||
StatusCode.Expired,
|
||||
rootCause.toString()
|
||||
)
|
||||
|
||||
else -> VerificationResult(StatusCode.UnknownCert, rootCause.toString())
|
||||
}
|
||||
}
|
||||
// In non-test configurations we should have caught expiry errors earlier and
|
||||
// can simply return an unknown cert error without digging through the exception
|
||||
// cause chain.
|
||||
return VerificationResult(StatusCode.UnknownCert, e.toString())
|
||||
}
|
||||
|
||||
// TEST ONLY: Mock test suite cannot attempt to check revocation status if no OSCP data has been stapled,
|
||||
// because Android requires certificates to an specify OCSP responder for network fetch in this case.
|
||||
// If in testing w/o OCSP stapled, short-circuit here - only prior checks apply.
|
||||
if (false && (mockKeystore.size() != 0) && (ocspResponse == null)) {
|
||||
return VerificationResult(StatusCode.Ok)
|
||||
}
|
||||
|
||||
// Try to check the revocation status of the cert, if it is supported.
|
||||
//
|
||||
// This is supported at >= API 24, but we're supporting 22 (Android 5) for the best
|
||||
// compatibility.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
// Revocation checking disabled to fix missing OCSP responder issue on Proton Drive API
|
||||
// return VerificationResult(StatusCode.Ok)
|
||||
} else {
|
||||
// This is allowed to be skipped since revocation checking is best-effort.
|
||||
Log.w(TAG, "did not attempt to validate OCSP due to Android version")
|
||||
}
|
||||
|
||||
return VerificationResult(StatusCode.Ok)
|
||||
}
|
||||
|
||||
private fun verifyCertUsage(certificate: X509Certificate, allowedEkus: Array<String>): Boolean {
|
||||
val ekus = try {
|
||||
certificate.extendedKeyUsage
|
||||
}
|
||||
// This should be unreachable, but could happen.
|
||||
catch (_: CertificateParsingException) {
|
||||
return false
|
||||
} catch (_: NullPointerException) {
|
||||
// According to Chromium's implementation, this can crash when the EKU data is malformed.
|
||||
Log.w(TAG, "exception handling certificate EKU")
|
||||
return false
|
||||
} ?: return true // If the list is empty, we have nothing to do.
|
||||
|
||||
return ekus.any { allowedEkus.contains(it) }
|
||||
}
|
||||
|
||||
// Android hashes a principal using the first four bytes of its MD5 digest, encoded in
|
||||
// lowercase hex and reversed.
|
||||
//
|
||||
// Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=339
|
||||
private fun hashPrincipal(principal: X500Principal): String {
|
||||
val hexDigits = "0123456789abcdef".toCharArray()
|
||||
val digest = MessageDigest.getInstance("MD5").digest(principal.encoded)
|
||||
val hexChars = CharArray(8)
|
||||
|
||||
for (i in 0..3) {
|
||||
// Kotlin doesn't support bitwise operators for bytes, only Int and Long.
|
||||
val digestByte = digest[3 - i].toInt()
|
||||
hexChars[2 * i] = hexDigits[(digestByte shr 4) and 0xf]
|
||||
hexChars[2 * i + 1] = hexDigits[digestByte and 0xf]
|
||||
}
|
||||
|
||||
return String(hexChars)
|
||||
}
|
||||
|
||||
// Check if CA root is known or not.
|
||||
// Known means installed in root CA store, either a preset public CA or a custom one installed by an enterprise/user.
|
||||
//
|
||||
// Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=351
|
||||
fun isKnownRoot(root: X509Certificate): Boolean {
|
||||
// System keystore and cert directory must be non-null to perform checking
|
||||
systemKeystore?.let { loadedSystemKeystore ->
|
||||
systemCertificateDirectory?.let { loadedSystemCertificateDirectory ->
|
||||
|
||||
// Check the in-memory cache first
|
||||
val key = Pair(root.subjectX500Principal, root.publicKey)
|
||||
if (systemTrustAnchorCache.contains(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// System trust anchors are stored under a hash of the principal.
|
||||
// In case of collisions, append number.
|
||||
val hash = hashPrincipal(root.subjectX500Principal)
|
||||
var i = 0
|
||||
while (true) {
|
||||
val alias = "$hash.$i"
|
||||
|
||||
if (!File(loadedSystemCertificateDirectory, alias).exists()) {
|
||||
break
|
||||
}
|
||||
|
||||
val anchor = loadedSystemKeystore.getCertificate("system:$alias")
|
||||
|
||||
// It's possible for `anchor` to be `null` if the user deleted a trust anchor.
|
||||
// Continue iterating as there may be further collisions after the deleted anchor.
|
||||
if (anchor == null) {
|
||||
continue
|
||||
// This should never happen
|
||||
} else if (anchor !is X509Certificate) {
|
||||
// SAFETY: This logs a unique identifier (hash value) only in cases where a file within the
|
||||
// system's root trust store is not a valid X509 certificate (extremely unlikely error).
|
||||
// The hash doesn't tell us any sensitive information about the invalid cert or reveal any of
|
||||
// its contents - it just lets us ID the bad file if a user is having TLS failure issues.
|
||||
Log.e(TAG, "anchor is not a certificate, alias: $alias")
|
||||
continue
|
||||
// If subject and public key match, it's a system root.
|
||||
} else {
|
||||
if ((root.subjectX500Principal == anchor.subjectX500Principal) && (root.publicKey == anchor.publicKey)) {
|
||||
systemTrustAnchorCache.add(key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not found in cache or store: non-public
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
|
||||
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
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
|
||||
white silhouette rather than the two-tone drawable.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<!--
|
||||
`pstr_core::appearance::Palette::PROTON.background` — the shipped default,
|
||||
and the only colour in this file. It is what the window is painted before
|
||||
the stored palette has been read; from then on
|
||||
MainActivity.dressWindow overrides it with the flavour in force.
|
||||
-->
|
||||
<color name="window_background">#0E0E12</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">proton-stream</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,23 @@
|
||||
<resources>
|
||||
<!--
|
||||
The window before Compose owns it.
|
||||
|
||||
Everything about the *look* is decided by the stored palette and applied
|
||||
in MainActivity.dressWindow: the background colour here is only the one
|
||||
the shipped default resolves to, so that a first-ever launch — before
|
||||
there is a theme file to read — starts on the right colour rather than on
|
||||
the platform's white. The bar polarity is likewise set from the palette;
|
||||
what is left here is the dark reading, which is right for four of the five
|
||||
flavours and is corrected in the same frame for the fifth.
|
||||
|
||||
A hex literal from one specific flavour used to live here, and it was the
|
||||
reason every cold start flashed Catppuccin Mocha's base regardless of what
|
||||
the viewer had chosen.
|
||||
-->
|
||||
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightNavigationBar">false</item>
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,42 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import androidx.work.NetworkType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class DownloadCoordinatorTest {
|
||||
@Test
|
||||
fun `wifi only downloads require an unmetered network`() {
|
||||
assertEquals(NetworkType.UNMETERED, requiredNetworkType(wifiOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloads allow any connected network when wifi only is disabled`() {
|
||||
assertEquals(NetworkType.CONNECTED, requiredNetworkType(wifiOnly = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreground notification id is stable and positive per work request`() {
|
||||
val workId = UUID.fromString("2f0ba9bc-8936-439b-88fd-cb2741a17653")
|
||||
assertEquals(stableNotificationId(workId), stableNotificationId(workId))
|
||||
assertTrue(stableNotificationId(workId) > 0)
|
||||
val otherWorkId = UUID.fromString("932e1de4-4359-423a-b5e8-af3e43086ab1")
|
||||
assertTrue(stableNotificationId(workId) != stableNotificationId(otherWorkId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `share cancellation tags cannot collide with the global queue tag`() {
|
||||
assertEquals("offline-share:share-1", DownloadCoordinator.shareTag("share-1"))
|
||||
assertTrue(DownloadCoordinator.shareTag("share-1") != DownloadCoordinator.TAG)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `episode work names are stable per share and link`() {
|
||||
assertEquals(
|
||||
"offline-share-1-link-2",
|
||||
DownloadCoordinator.workName("share-1", "link-2"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DownloadStateStoreTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var store: DownloadStateStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit().clear().commit()
|
||||
store = DownloadStateStore(context)
|
||||
}
|
||||
|
||||
private fun record(
|
||||
shareId: String = "share-1",
|
||||
linkId: String = "link-1",
|
||||
label: String = "S01E01.mkv",
|
||||
status: String = RetainedDownload.STATUS_RUNNING,
|
||||
error: String? = null,
|
||||
) = RetainedDownload(
|
||||
shareId = shareId,
|
||||
volumeId = "volume-1",
|
||||
linkId = linkId,
|
||||
label = label,
|
||||
downloaded = 4L * 1024 * 1024,
|
||||
total = 761L * 1024 * 1024,
|
||||
status = status,
|
||||
error = error,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a record survives a round trip unchanged`() {
|
||||
val original = record()
|
||||
store.put(original)
|
||||
assertEquals(original, store.get("share-1", "link-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absent record reads as null rather than a default`() {
|
||||
assertNull(store.get("share-1", "link-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an error message round trips and a null one stays null`() {
|
||||
store.put(record(error = "no space left on device"))
|
||||
assertEquals("no space left on device", store.get("share-1", "link-1")?.error)
|
||||
|
||||
store.put(record(error = null))
|
||||
assertNull(store.get("share-1", "link-1")?.error)
|
||||
}
|
||||
|
||||
/**
|
||||
* `encode` writes `error` through `JSONObject.put`, which *removes* the key
|
||||
* when the value is null. `decode` then reads `optString`, which answers ""
|
||||
* rather than null. The blank check is the only thing standing between a
|
||||
* cleared error and a download that renders as permanently failed.
|
||||
*/
|
||||
@Test
|
||||
fun `a blank error is not read as a failure message`() {
|
||||
writeRaw(
|
||||
"share-1",
|
||||
"link-1",
|
||||
baseJson().put("error", ""),
|
||||
)
|
||||
assertNull(store.get("share-1", "link-1")?.error)
|
||||
}
|
||||
|
||||
/** Rows written before `label` existed fall back to the link id. */
|
||||
@Test
|
||||
fun `a record with no label is captioned with its link id`() {
|
||||
writeRaw("share-1", "link-1", baseJson().apply { remove("label") })
|
||||
assertEquals("link-1", store.get("share-1", "link-1")?.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt row is skipped rather than failing the whole list`() {
|
||||
store.put(record(linkId = "link-1", label = "good.mkv"))
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("share-1\u001flink-broken", "{ not json")
|
||||
.commit()
|
||||
|
||||
val records = store.records()
|
||||
assertEquals(1, records.size)
|
||||
assertEquals("good.mkv", records.first().label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records are ordered by label without regard to case`() {
|
||||
store.put(record(linkId = "a", label = "beta.mkv"))
|
||||
store.put(record(linkId = "b", label = "Alpha.mkv"))
|
||||
assertEquals(listOf("Alpha.mkv", "beta.mkv"), store.records().map { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing one share leaves the others alone`() {
|
||||
store.put(record(shareId = "share-1", linkId = "a"))
|
||||
store.put(record(shareId = "share-2", linkId = "b"))
|
||||
|
||||
store.removeShare("share-1")
|
||||
|
||||
assertEquals(listOf("share-2"), store.records().map { it.shareId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear forgets everything`() {
|
||||
store.put(record(linkId = "a"))
|
||||
store.put(record(linkId = "b"))
|
||||
store.clear()
|
||||
assertTrue(store.records().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* The key is what `DownloadCoordinator` and `OfflineDownloadWorker` agree on
|
||||
* across processes; two shares may legitimately hold the same link id.
|
||||
*/
|
||||
@Test
|
||||
fun `the key separates share from link so ids cannot run together`() {
|
||||
store.put(record(shareId = "a", linkId = "b-c"))
|
||||
store.put(record(shareId = "a-b", linkId = "c"))
|
||||
assertEquals(2, store.records().size)
|
||||
}
|
||||
|
||||
private fun baseJson() = JSONObject()
|
||||
.put("share", "share-1")
|
||||
.put("volume", "volume-1")
|
||||
.put("link", "link-1")
|
||||
.put("label", "S01E01.mkv")
|
||||
.put("downloaded", 0)
|
||||
.put("total", 0)
|
||||
.put("status", RetainedDownload.STATUS_QUEUED)
|
||||
|
||||
private fun writeRaw(shareId: String, linkId: String, json: JSONObject) {
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("$shareId\u001f$linkId", json.toString())
|
||||
.commit()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "offline_download_state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/**
|
||||
* That every Material colour role is answered by the palette.
|
||||
*
|
||||
* Material names roughly fifty roles and a `ColorScheme` is built by copying a
|
||||
* baseline, so a role nobody names keeps Material's own purple-and-grey —
|
||||
* silently, and only where that particular role happens to be drawn. That is
|
||||
* how `surfaceContainerHighest` went unnoticed: nothing referred to it by name,
|
||||
* but it is what `Card` fills with, so every tile, episode row, share and
|
||||
* download in the app was baseline neutral on a flavoured page.
|
||||
*
|
||||
* The check is reflective rather than a list, because a list is exactly as
|
||||
* incomplete as the mapping it was written from — and because Material adds
|
||||
* roles between versions, and a role added tomorrow should fail this the day
|
||||
* the dependency moves.
|
||||
*/
|
||||
class SchemeRolesTest {
|
||||
/**
|
||||
* A palette whose every colour is distinct and none of which Material would
|
||||
* ever produce, so that "this role came from the palette" is decidable by
|
||||
* looking at the value.
|
||||
*/
|
||||
private fun sentinels(light: Boolean) = PaletteRecord(
|
||||
background = 0xFF010203u,
|
||||
surface = 0xFF040506u,
|
||||
sunken = 0xFF070809u,
|
||||
card = 0xFF0A0B0Cu,
|
||||
cardHover = 0xFF0D0E0Fu,
|
||||
border = 0xFF101112u,
|
||||
text = 0xFF131415u,
|
||||
muted = 0xFF161718u,
|
||||
accent = 0xFF191A1Bu,
|
||||
accentAlt = 0xFF1C1D1Eu,
|
||||
accentDim = 0xFF1F2021u,
|
||||
onAccent = 0xFF222324u,
|
||||
danger = 0xFF252627u,
|
||||
elevated = 0xFF28292Au,
|
||||
dangerDim = 0xFF2B2C2Du,
|
||||
light = light,
|
||||
)
|
||||
|
||||
/** Every role on a `ColorScheme`, read by name, whatever the version names. */
|
||||
private fun roles(scheme: ColorScheme): Map<String, Color> =
|
||||
ColorScheme::class.java.declaredMethods
|
||||
.filter { it.name.startsWith("get") && it.parameterCount == 0 && it.returnType == Long::class.java }
|
||||
.associate { method ->
|
||||
method.isAccessible = true
|
||||
// Kotlin mangles the getter of a value-class property:
|
||||
// `getPrimary-0d7_KjU`. The property is what the name says up
|
||||
// to the dash.
|
||||
method.name.removePrefix("get").substringBefore('-') to
|
||||
Color((method.invoke(scheme) as Long).toULong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every role is answered by the palette`() {
|
||||
for (light in listOf(false, true)) {
|
||||
val palette = sentinels(light)
|
||||
val allowed = buildSet {
|
||||
addAll(
|
||||
listOf(
|
||||
palette.background, palette.surface, palette.sunken, palette.card,
|
||||
palette.cardHover, palette.border, palette.text, palette.muted,
|
||||
palette.accent, palette.accentAlt, palette.accentDim, palette.onAccent,
|
||||
palette.danger, palette.elevated, palette.dangerDim,
|
||||
).map { Color(it.toInt()) },
|
||||
)
|
||||
// The two roles that are deliberately not a palette colour: no
|
||||
// elevation tint at all, and a scrim that is an absence of
|
||||
// light rather than a hue.
|
||||
add(Color.Transparent)
|
||||
add(Color.Black)
|
||||
}
|
||||
|
||||
val leaked = roles(schemeOf(palette)).filterValues { it !in allowed }
|
||||
assertTrue(
|
||||
"light=$light: ${leaked.keys.sorted()} still carry Material's baseline",
|
||||
leaked.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The count is asserted too. A reflective sweep that found no roles at all
|
||||
* would pass the check above for the wrong reason.
|
||||
*/
|
||||
@Test
|
||||
fun `the sweep sees the whole scheme`() {
|
||||
val count = roles(darkColorScheme()).size
|
||||
assertTrue("only $count roles found; the reflective sweep is not working", count >= 40)
|
||||
assertEquals(count, roles(lightColorScheme()).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which baseline was copied stops being observable once every role is
|
||||
* mapped — and that is the point.
|
||||
*
|
||||
* `light` still decides plenty, but it decides it in Rust, where it picks
|
||||
* the flavour's colours. By the time a palette reaches Kotlin the
|
||||
* light-or-dark question is already answered *inside* the colours, so two
|
||||
* palettes that differ only in the flag have to produce the same scheme. If
|
||||
* they ever stop doing so, something is reading `light` here rather than
|
||||
* reading the colours.
|
||||
*/
|
||||
@Test
|
||||
fun `the baseline no longer shows through`() {
|
||||
assertEquals(roles(schemeOf(sentinels(false))), roles(schemeOf(sentinels(true))))
|
||||
assertEquals(Color.Transparent, schemeOf(sentinels(false)).surfaceTint)
|
||||
}
|
||||
|
||||
/** The fallback goes through the same mapping as any other palette. */
|
||||
@Test
|
||||
fun `the shipped fallback is fully mapped too`() {
|
||||
val scheme = schemeOf(ProtonFallback)
|
||||
assertEquals(Color(ProtonFallback.elevated.toInt()), scheme.surfaceContainerHighest)
|
||||
assertEquals(Color(ProtonFallback.card.toInt()), scheme.surfaceContainer)
|
||||
assertEquals(Color(ProtonFallback.dangerDim.toInt()), scheme.errorContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* That the type ramp is the ramp, and the corner radius is the radius.
|
||||
*
|
||||
* The two clients share a palette because one Rust function resolves it for
|
||||
* both. Type and shape have no such bridge — they are named in
|
||||
* `pstr_app::theme::Role` and copied here — so what stops them drifting is this
|
||||
* test and the numbers in it. Change the Rust ramp and this fails; that is the
|
||||
* point of it.
|
||||
*
|
||||
* Every style is named rather than checked against a set, because "some rung"
|
||||
* is not the property that matters: `bodyMedium`'s Material default is 14sp,
|
||||
* which is a rung, so a `bodyMedium` nobody mapped would pass a looser check
|
||||
* while sitting one step off where it belongs.
|
||||
*/
|
||||
class TypeRampTest {
|
||||
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
|
||||
private val expected = mapOf(
|
||||
"displayLarge" to 26f,
|
||||
"displayMedium" to 26f,
|
||||
"displaySmall" to 20f,
|
||||
"headlineLarge" to 26f,
|
||||
"headlineMedium" to 26f,
|
||||
"headlineSmall" to 18f,
|
||||
"titleLarge" to 20f,
|
||||
"titleMedium" to 17f,
|
||||
"titleSmall" to 15f,
|
||||
"bodyLarge" to 14f,
|
||||
"bodyMedium" to 13f,
|
||||
"bodySmall" to 12f,
|
||||
"labelLarge" to 13f,
|
||||
"labelMedium" to 12f,
|
||||
"labelSmall" to 11f,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every text style sits on the shared ramp`() {
|
||||
expected.forEach { (name, size) ->
|
||||
assertEquals(name, size, styles().getValue(name).fontSize.value, 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material adds type styles between versions, and one added tomorrow would
|
||||
* arrive at whatever size Material chose for it. The list above is only as
|
||||
* complete as the day it was written unless something says so.
|
||||
*/
|
||||
@Test
|
||||
fun `no text style is left at Material's own size`() {
|
||||
assertEquals(expected.keys, styles().keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading is derived from the size, so the only thing worth asserting is
|
||||
* that it *was* derived — a style that kept Material's line height carries
|
||||
* a number with no relation to its own size.
|
||||
*/
|
||||
@Test
|
||||
fun `line height follows the size it belongs to`() {
|
||||
styles().forEach { (name, style) ->
|
||||
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ramp has no rung the styles never reach`() {
|
||||
val drawn = styles().values.map { it.fontSize.value }.toSet()
|
||||
ramp.forEach { rung -> assertTrue("$rung is named but never drawn", rung in drawn) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every surface is drawn at the desktop's radius`() {
|
||||
listOf(ProtonShapes.small, ProtonShapes.medium, ProtonShapes.large).forEach { shape ->
|
||||
// `CornerSize` resolves against a size and a density, and neither is
|
||||
// available to a unit test; `toString` is the only handle on the
|
||||
// value that does not need a composition.
|
||||
assertTrue("$shape is not radius $CORNER", shape.toString().contains("$CORNER"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every text style a caller can set, read by name, whatever the version
|
||||
* names.
|
||||
*
|
||||
* Material 1.4 carries a second, `Emphasized` copy of all fifteen for its
|
||||
* expressive components. Those are `internal` — the constructor takes them
|
||||
* but Kotlin will not let this module pass them — so they keep Material's
|
||||
* own sizes and nothing here can do anything about it. Their getters are
|
||||
* name-mangled with `$material3`, which is how they are told apart from a
|
||||
* public style that genuinely went unmapped.
|
||||
*/
|
||||
private fun styles(): Map<String, TextStyle> = Typography::class.java.declaredMethods
|
||||
.filter { it.parameterCount == 0 && it.returnType == TextStyle::class.java }
|
||||
.filterNot { '$' in it.name }
|
||||
.associate { method ->
|
||||
method.isAccessible = true
|
||||
method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) to
|
||||
method.invoke(ProtonTypography) as TextStyle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Robolectric ships no Android image for API 36 yet, and defaults to the app's
|
||||
# targetSdk, so host tests would fail to start. 34 is the newest it has and is
|
||||
# well above minSdk 31; anything whose behaviour actually turns on the API level
|
||||
# belongs in androidTest, on a real device, not here.
|
||||
sdk=34
|
||||
|
||||
# ProtonStreamApplication.onCreate() calls System.loadLibrary("pstr_android"),
|
||||
# which has no host build. Host tests get a plain Application; everything that
|
||||
# needs the bridge is an instrumentation test by construction — see
|
||||
# androidTest/.../UniFfiBoundaryTest.
|
||||
application=android.app.Application
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,49 @@
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
kotlin = "2.2.21"
|
||||
compose-bom = "2026.06.00"
|
||||
activity = "1.11.0"
|
||||
lifecycle = "2.9.4"
|
||||
navigation = "2.9.5"
|
||||
work = "2.10.5"
|
||||
adaptive = "1.1.0"
|
||||
jna = "5.17.0"
|
||||
junit = "4.13.2"
|
||||
robolectric = "4.16"
|
||||
androidx-test-core = "1.7.0"
|
||||
androidx-test-junit = "1.3.0"
|
||||
androidx-test-runner = "1.7.0"
|
||||
ktlint-plugin = "13.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
||||
androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" }
|
||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||
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-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,21 @@
|
||||
import java.io.File
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "proton-stream-android"
|
||||
include(":app")
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "pstr-android"
|
||||
description = "Android language bridge for proton-stream."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license-file = "LICENSE"
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
bindgen = ["uniffi/cli"]
|
||||
|
||||
[[bin]]
|
||||
name = "uniffi-bindgen"
|
||||
path = "src/bin/uniffi-bindgen.rs"
|
||||
required-features = ["bindgen"]
|
||||
|
||||
[dependencies]
|
||||
pstr-core.workspace = true
|
||||
pstr-meta.workspace = true
|
||||
pstr-stream.workspace = true
|
||||
tokio.workspace = true
|
||||
parking_lot.workspace = true
|
||||
thiserror.workspace = true
|
||||
uniffi.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
log = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
# Match rustls-platform-verifier's JNI types: Java objects from different
|
||||
# major `jni` releases cannot be passed across the verifier's API boundary.
|
||||
jni = "0.21.1"
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
android_logger = "0.14"
|
||||
@@ -0,0 +1,232 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
||||
[bindings.kotlin]
|
||||
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
||||
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||
disable_java_cleaner = true
|
||||
@@ -27,3 +27,5 @@ egui.workspace = true
|
||||
image.workspace = true
|
||||
sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
+153
-32
@@ -14,7 +14,9 @@ use pstr_core::config::AppDirs;
|
||||
use pstr_core::library::Library;
|
||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
|
||||
|
||||
use crate::engine::{Engine, Event, ImageCache, describe_failures, watch_state};
|
||||
use crate::engine::{
|
||||
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
|
||||
};
|
||||
use crate::playback::{Playback, PlaybackTarget};
|
||||
use crate::ui::player::UpNextCard;
|
||||
use crate::{theme, ui};
|
||||
@@ -48,6 +50,8 @@ impl FrameTimer {
|
||||
Page::Library => "library",
|
||||
Page::Title(_) => "title",
|
||||
Page::Shares => "shares",
|
||||
Page::Downloads => "downloads",
|
||||
Page::Settings => "settings",
|
||||
Page::Player => "player",
|
||||
},
|
||||
}
|
||||
@@ -81,6 +85,9 @@ pub enum Page {
|
||||
/// One title, by [`pstr_core::library::Title::key`].
|
||||
Title(String),
|
||||
Shares,
|
||||
Downloads,
|
||||
/// How the app looks, how it plays, and what it looks up.
|
||||
Settings,
|
||||
/// The picture, filling the window. Leaving this page does not stop
|
||||
/// playback — the transport bar at the bottom is how you get back to it.
|
||||
Player,
|
||||
@@ -90,6 +97,12 @@ pub enum Page {
|
||||
pub enum Action {
|
||||
Goto(Page),
|
||||
Play(PlaybackTarget),
|
||||
/// Download one or more files as complete local copies.
|
||||
MakeOffline(Vec<PlaybackTarget>),
|
||||
PauseDownload(DownloadKey),
|
||||
ResumeDownload(DownloadKey),
|
||||
CancelDownload(DownloadKey),
|
||||
RemoveDownload(DownloadKey, bool),
|
||||
/// Crawl one share, or every share.
|
||||
Crawl(Option<String>),
|
||||
AddShare {
|
||||
@@ -280,6 +293,9 @@ pub struct App {
|
||||
pub opening: Option<String>,
|
||||
pub connecting: bool,
|
||||
pub crawling: bool,
|
||||
pub downloads: Vec<DownloadItem>,
|
||||
pub offline_files: std::collections::HashSet<DownloadKey>,
|
||||
pub confirm_partial_delete: Option<DownloadKey>,
|
||||
pub status: Option<Status>,
|
||||
}
|
||||
|
||||
@@ -293,6 +309,7 @@ impl App {
|
||||
// theme, and a window that paints one frame in the default palette
|
||||
// before switching is a window that flashes on every launch.
|
||||
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||
theme::install_font_fallbacks(&cc.egui_ctx);
|
||||
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||
|
||||
// Paint from the catalog immediately; the network catches up. A library
|
||||
@@ -326,6 +343,9 @@ impl App {
|
||||
opening: None,
|
||||
connecting: true,
|
||||
crawling: false,
|
||||
downloads: Vec::new(),
|
||||
offline_files: std::collections::HashSet::new(),
|
||||
confirm_partial_delete: None,
|
||||
status: None,
|
||||
})
|
||||
}
|
||||
@@ -507,6 +527,8 @@ impl App {
|
||||
self.note(ctx, text, true);
|
||||
}
|
||||
Event::Status(text) => self.note(ctx, text, false),
|
||||
Event::Downloads(downloads) => self.downloads = downloads,
|
||||
Event::OfflineFiles(files) => self.offline_files = files,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -674,9 +696,27 @@ impl App {
|
||||
// click on a row, a keypress, autoplay — so the episode's name
|
||||
// is looked up here rather than in each of them.
|
||||
target.episode_name = self.episode_name(&target);
|
||||
target.track_prefs = self
|
||||
.engine
|
||||
.title_track_prefs(&target.title_key)
|
||||
.map(Box::new);
|
||||
self.opening = Some(target.name.clone());
|
||||
self.engine.play(target);
|
||||
}
|
||||
Action::MakeOffline(targets) => {
|
||||
self.note(ctx, "downloading for offline use…", false);
|
||||
self.engine.make_offline(targets);
|
||||
}
|
||||
Action::PauseDownload(key) => self.engine.pause_download(&key),
|
||||
Action::ResumeDownload(key) => self.engine.resume_download(&key),
|
||||
Action::CancelDownload(key) => self.engine.cancel_download(&key),
|
||||
Action::RemoveDownload(key, remove_partial) => {
|
||||
if remove_partial {
|
||||
self.confirm_partial_delete = Some(key);
|
||||
} else {
|
||||
self.engine.remove_download(key, false);
|
||||
}
|
||||
}
|
||||
Action::SetMetadataConfig(config) => {
|
||||
self.settings = config.clone();
|
||||
self.engine.set_metadata_config(config);
|
||||
@@ -832,22 +872,24 @@ impl App {
|
||||
.and_then(|track| track.language.clone())
|
||||
})
|
||||
});
|
||||
let mut prefs = self.engine.playback_prefs();
|
||||
match kind {
|
||||
pstr_player::TrackKind::Audio => prefs.audio_language = language,
|
||||
pstr_player::TrackKind::Subtitle => {
|
||||
prefs.subtitles = id.is_some();
|
||||
// Subtitles turned off keep the language they were
|
||||
// last shown in, so turning them back on picks up
|
||||
// where the viewer left off rather than at whatever
|
||||
// the file defaults to.
|
||||
if id.is_some() {
|
||||
prefs.subtitle_language = language;
|
||||
if let Some(playback) = &self.playback {
|
||||
let mut show = self
|
||||
.engine
|
||||
.title_track_prefs(&playback.target.title_key)
|
||||
.unwrap_or_default();
|
||||
match kind {
|
||||
pstr_player::TrackKind::Audio => show.audio_language = language,
|
||||
pstr_player::TrackKind::Subtitle => {
|
||||
show.subtitles = id.is_some();
|
||||
if id.is_some() {
|
||||
show.subtitle_language = language;
|
||||
}
|
||||
}
|
||||
pstr_player::TrackKind::Video => {}
|
||||
}
|
||||
pstr_player::TrackKind::Video => {}
|
||||
self.engine
|
||||
.set_title_track_prefs(playback.target.title_key.clone(), show);
|
||||
}
|
||||
self.engine.set_playback_prefs(prefs, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,19 +899,44 @@ impl App {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new("proton-stream")
|
||||
.size(18.0)
|
||||
theme::Role::Heading
|
||||
.rich("proton-stream")
|
||||
.strong()
|
||||
.color(theme::accent()),
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
|
||||
let on_library = matches!(self.page, Page::Library | Page::Title(_));
|
||||
if ui::tab(ui, on_library, "Library").clicked() {
|
||||
actions.push(Action::Goto(Page::Library));
|
||||
}
|
||||
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
|
||||
actions.push(Action::Goto(Page::Shares));
|
||||
let active = self
|
||||
.downloads
|
||||
.iter()
|
||||
.filter(|download| {
|
||||
matches!(
|
||||
download.state,
|
||||
crate::engine::DownloadState::Queued
|
||||
| crate::engine::DownloadState::Running
|
||||
| crate::engine::DownloadState::Paused
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let downloads = if active == 0 {
|
||||
"Downloads".to_owned()
|
||||
} else {
|
||||
format!("Downloads ({active})")
|
||||
};
|
||||
let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings];
|
||||
let clicked = ui::tabs(
|
||||
ui,
|
||||
ui.id().with("nav"),
|
||||
&[
|
||||
("Library", on_library),
|
||||
("Shares", self.page == Page::Shares),
|
||||
(&downloads, self.page == Page::Downloads),
|
||||
("Settings", self.page == Page::Settings),
|
||||
],
|
||||
);
|
||||
if let Some(index) = clicked {
|
||||
actions.push(Action::Goto(pages[index].clone()));
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -968,7 +1035,7 @@ impl eframe::App for App {
|
||||
{
|
||||
let mut search = std::mem::take(&mut self.search);
|
||||
let neighbours = self.neighbours();
|
||||
let prefs = ui::shares::Prefs {
|
||||
let prefs = ui::settings::Prefs {
|
||||
autoplay: self.engine.playback_prefs().autoplay_next,
|
||||
appearance: self.engine.appearance(),
|
||||
};
|
||||
@@ -985,6 +1052,7 @@ impl eframe::App for App {
|
||||
NAV_MARGIN.y as i8,
|
||||
)))
|
||||
.show(ui, |ui| {
|
||||
let shadow = ui.painter().add(egui::Shape::Noop);
|
||||
let background = ui.painter().add(egui::Shape::Noop);
|
||||
self.navigation(ui, &mut actions, &mut search);
|
||||
// Full width from the space the panel was given, height from
|
||||
@@ -997,6 +1065,13 @@ impl eframe::App for App {
|
||||
.expand2(NAV_MARGIN);
|
||||
ui.painter()
|
||||
.set(background, theme::bar_shape(ui.ctx(), bar));
|
||||
// Past the panel's own edge, or the half of the shadow that
|
||||
// falls on the page below it — the only half worth drawing
|
||||
// — is clipped away.
|
||||
ui.painter().with_clip_rect(ui.ctx().viewport_rect()).set(
|
||||
shadow,
|
||||
theme::bar_shadow(true).as_shape(bar, egui::CornerRadius::ZERO),
|
||||
);
|
||||
});
|
||||
self.search = search;
|
||||
|
||||
@@ -1017,6 +1092,9 @@ impl eframe::App for App {
|
||||
playback,
|
||||
opening,
|
||||
status,
|
||||
downloads,
|
||||
offline_files,
|
||||
confirm_partial_delete,
|
||||
..
|
||||
} = self;
|
||||
|
||||
@@ -1029,6 +1107,22 @@ impl eframe::App for App {
|
||||
.inner_margin(egui::Margin::symmetric(16, 10)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
// Above the bar rather than below it: this one is at the
|
||||
// bottom of the window, so the page it separates itself
|
||||
// from is the one over it.
|
||||
//
|
||||
// Clipped to the strip it falls on, because a `Frame`
|
||||
// paints its fill *under* its contents and a shadow
|
||||
// added here would otherwise lay its opaque middle over
|
||||
// the bar it is cast by.
|
||||
let bar = ui.max_rect().expand2(egui::vec2(16.0, 10.0));
|
||||
let above = egui::Rect::from_min_max(
|
||||
egui::pos2(bar.left(), bar.top() - 16.0),
|
||||
egui::pos2(bar.right(), bar.top()),
|
||||
);
|
||||
ui.painter()
|
||||
.with_clip_rect(above)
|
||||
.add(theme::bar_shadow(false).as_shape(bar, egui::CornerRadius::ZERO));
|
||||
transport_panel(
|
||||
ui,
|
||||
playback.as_ref(),
|
||||
@@ -1055,7 +1149,7 @@ impl eframe::App for App {
|
||||
} else {
|
||||
theme::muted()
|
||||
};
|
||||
ui.label(egui::RichText::new(&line.text).size(12.0).color(colour));
|
||||
ui.label(theme::Role::Caption.rich(&line.text).color(colour));
|
||||
});
|
||||
// Repaint once the line is due to disappear, or it lingers
|
||||
// until something else happens to cause a frame.
|
||||
@@ -1086,18 +1180,22 @@ impl eframe::App for App {
|
||||
Page::Library => {
|
||||
ui::library::show(ui, &mut art, library, search, &mut actions)
|
||||
}
|
||||
Page::Title(key) => {
|
||||
ui::title::show(ui, &mut art, library, key, &mut actions)
|
||||
}
|
||||
Page::Shares => ui::shares::show(
|
||||
Page::Title(key) => ui::title::show(
|
||||
ui,
|
||||
shares,
|
||||
form,
|
||||
settings,
|
||||
prefs,
|
||||
api_key,
|
||||
&mut art,
|
||||
library,
|
||||
key,
|
||||
ui::title::OfflineView {
|
||||
downloads,
|
||||
files: offline_files,
|
||||
},
|
||||
&mut actions,
|
||||
),
|
||||
Page::Shares => ui::shares::show(ui, shares, form, &mut actions),
|
||||
Page::Settings => {
|
||||
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
||||
}
|
||||
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
|
||||
// Drawn above, without any of these panels.
|
||||
Page::Player => {}
|
||||
}
|
||||
@@ -1116,6 +1214,27 @@ impl eframe::App for App {
|
||||
};
|
||||
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
|
||||
}
|
||||
|
||||
if let Some(key) = confirm_partial_delete.clone() {
|
||||
egui::Window::new("Delete partial download?")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(
|
||||
"This discards downloaded partial bytes. The online source is not changed.",
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Keep partial").clicked() {
|
||||
*confirm_partial_delete = None;
|
||||
}
|
||||
if ui.button("Delete partial").clicked() {
|
||||
engine.remove_download(key.clone(), true);
|
||||
*confirm_partial_delete = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for action in actions {
|
||||
@@ -1136,6 +1255,8 @@ impl eframe::App for App {
|
||||
playback.stop_and_wait(std::time::Duration::from_secs(2));
|
||||
}
|
||||
self.playback = None;
|
||||
self.engine
|
||||
.shutdown_downloads(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+981
-59
File diff suppressed because it is too large.
Load diff
@@ -55,7 +55,7 @@ const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const POLL_TIMEOUT: f64 = 0.05;
|
||||
|
||||
/// What is being played, and where it came from.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PlaybackTarget {
|
||||
pub share_id: String,
|
||||
pub volume_id: String,
|
||||
@@ -76,6 +76,8 @@ pub struct PlaybackTarget {
|
||||
pub episode_name: Option<String>,
|
||||
/// Where to resume, when the viewer had been here before.
|
||||
pub resume_at: Option<f64>,
|
||||
/// Per-show track choices, filled by the app just before opening.
|
||||
pub track_prefs: Option<Box<pstr_core::catalog::TitleTrackPrefs>>,
|
||||
}
|
||||
|
||||
impl PlaybackTarget {
|
||||
@@ -102,6 +104,7 @@ impl PlaybackTarget {
|
||||
number: node.parsed.episode,
|
||||
episode_name: None,
|
||||
resume_at,
|
||||
track_prefs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +377,7 @@ fn build_player(
|
||||
// controller would be a second set of controls over the same file.
|
||||
on_screen_controller: false,
|
||||
default_keybindings: false,
|
||||
..from_prefs(prefs)
|
||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||
};
|
||||
|
||||
match Player::new(engine.runtime().clone(), config) {
|
||||
@@ -403,7 +406,7 @@ fn build_player(
|
||||
|
||||
let config = PlayerConfig {
|
||||
window_title: title,
|
||||
..from_prefs(prefs)
|
||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||
};
|
||||
let player = Player::new(engine.runtime().clone(), config)
|
||||
.map_err(|error| format!("start mpv: {error}"))?;
|
||||
@@ -411,13 +414,20 @@ fn build_player(
|
||||
}
|
||||
|
||||
/// The player defaults with the viewer's preferences folded in.
|
||||
fn from_prefs(prefs: &PlaybackPrefs) -> PlayerConfig {
|
||||
fn from_prefs(
|
||||
prefs: &PlaybackPrefs,
|
||||
show: Option<&pstr_core::catalog::TitleTrackPrefs>,
|
||||
) -> PlayerConfig {
|
||||
PlayerConfig {
|
||||
volume: prefs.volume,
|
||||
muted: prefs.muted,
|
||||
audio_language: prefs.audio_language.clone(),
|
||||
subtitle_language: prefs.subtitle_language.clone(),
|
||||
subtitles: prefs.subtitles,
|
||||
audio_language: show
|
||||
.and_then(|p| p.audio_language.clone())
|
||||
.or_else(|| prefs.audio_language.clone()),
|
||||
subtitle_language: show
|
||||
.and_then(|p| p.subtitle_language.clone())
|
||||
.or_else(|| prefs.subtitle_language.clone()),
|
||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||
..PlayerConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
+320
-361
@@ -23,11 +23,11 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{
|
||||
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
||||
Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
||||
ThemePreference, ViewportCommand, Visuals,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||
use pstr_core::appearance::Appearance;
|
||||
|
||||
/// Card geometry. The grid is built from these, so a change here moves
|
||||
/// everything together.
|
||||
@@ -36,11 +36,127 @@ pub const CARD_WIDTH: f32 = 232.0;
|
||||
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
||||
pub const CARD_GAP: f32 = 14.0;
|
||||
|
||||
/// Every colour the app draws with, resolved from a [`Flavor`] and an
|
||||
/// [`Accent`].
|
||||
/// The type ramp, named once.
|
||||
///
|
||||
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
|
||||
/// a drawing loop has to think about.
|
||||
/// Every size in the app comes from here. They used to be literals at the call
|
||||
/// site — `.size(17.0)` in one place and `.size(18.0)` in another for the same
|
||||
/// kind of heading — which is not a scale, it is forty independent decisions
|
||||
/// that happen to be close together. Naming them makes the ramp a thing that
|
||||
/// can be changed, and gives the Android client something to match: a shared
|
||||
/// palette settles colour, and this settles the other half.
|
||||
///
|
||||
/// Sizes rather than full `FontId`s because everything here is proportional;
|
||||
/// the one monospace style in the app is egui's own, set in [`apply`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// The name of the thing you are looking at, once per page.
|
||||
Display,
|
||||
/// An empty state, or the heading of a page that has no display line.
|
||||
Title,
|
||||
/// The brand in the nav bar, and a dialog's heading.
|
||||
Heading,
|
||||
/// A run of content under a rule.
|
||||
Section,
|
||||
/// A row that groups the rows under it — a season header.
|
||||
Subhead,
|
||||
/// Everything that is prose.
|
||||
Body,
|
||||
/// The text on a small control.
|
||||
Label,
|
||||
/// Context rather than content: counts, sizes, errors under a field.
|
||||
Caption,
|
||||
/// The smallest thing that stays legible — a badge over artwork.
|
||||
Micro,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub const fn size(self) -> f32 {
|
||||
match self {
|
||||
Self::Display => 26.0,
|
||||
Self::Title => 20.0,
|
||||
Self::Heading => 18.0,
|
||||
Self::Section => 17.0,
|
||||
Self::Subhead => 15.0,
|
||||
Self::Body => 14.0,
|
||||
Self::Label => 13.0,
|
||||
Self::Caption => 12.0,
|
||||
Self::Micro => 11.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// For the hand-painted parts, which lay text out through a `Painter` and
|
||||
/// never see a `RichText`.
|
||||
pub fn font(self) -> egui::FontId {
|
||||
egui::FontId::proportional(self.size())
|
||||
}
|
||||
|
||||
/// For the parts that go through egui's widgets.
|
||||
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
||||
egui::RichText::new(text).size(self.size())
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a real CJK fallback before the default egui fonts. egui ships a
|
||||
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
|
||||
/// CJK families. Native desktops already provide one, so use it when present.
|
||||
pub fn install_font_fallbacks(ctx: &egui::Context) {
|
||||
let Some((path, index)) = system_cjk_font() else {
|
||||
return;
|
||||
};
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mut data = egui::FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
let name = "system-cjk-fallback".to_owned();
|
||||
fonts.font_data.insert(name.clone(), Arc::new(data));
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
fonts.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
let output = std::process::Command::new("fc-match")
|
||||
.args(["-f", "%{file}\\n%{index}", "Noto Sans CJK JP"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = std::str::from_utf8(&output.stdout).ok()?;
|
||||
let mut lines = text.lines();
|
||||
let path = std::path::PathBuf::from(lines.next()?);
|
||||
let index = lines
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
path.is_file().then_some((path, index))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
|
||||
path.is_file().then_some((path, 0))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
let path = std::path::PathBuf::from("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc");
|
||||
path.is_file().then_some((path, 0))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Every colour the app draws with, as egui wants them.
|
||||
///
|
||||
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
||||
/// with the Android client, so a flavour means the same thing on both. This is
|
||||
/// only that palette converted into `Color32`, which is a type `pstr-core`
|
||||
/// deliberately knows nothing about.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
/// Behind everything: the page.
|
||||
@@ -63,15 +179,54 @@ pub struct Palette {
|
||||
/// The accent taken down into the background, for a pressed control and for
|
||||
/// selection behind text.
|
||||
pub accent_dim: Color32,
|
||||
/// Ink that stays readable on top of the accent. Chosen by the accent's
|
||||
/// luminance, because Catppuccin's dark flavours have *pale* accents and
|
||||
/// white-on-pastel is not readable at 13 px.
|
||||
/// Ink that stays readable on top of the accent.
|
||||
pub on_accent: Color32,
|
||||
pub danger: Color32,
|
||||
/// Whether this is a light theme.
|
||||
pub light: bool,
|
||||
}
|
||||
|
||||
const fn color(rgb: pstr_core::appearance::Rgb) -> Color32 {
|
||||
Color32::from_rgb(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The default, spelled out so the global has something to hold before
|
||||
/// [`apply`] first runs — a `Ui` built during startup, or a panic message
|
||||
/// painted before the engine exists, still gets colours.
|
||||
const PROTON: Self = Self::of(pstr_core::appearance::Palette::PROTON);
|
||||
|
||||
const fn of(palette: pstr_core::appearance::Palette) -> Self {
|
||||
Self {
|
||||
background: color(palette.background),
|
||||
surface: color(palette.surface),
|
||||
sunken: color(palette.sunken),
|
||||
card: color(palette.card),
|
||||
card_hover: color(palette.card_hover),
|
||||
border: color(palette.border),
|
||||
text: color(palette.text),
|
||||
muted: color(palette.muted),
|
||||
accent: color(palette.accent),
|
||||
accent_alt: color(palette.accent_alt),
|
||||
accent_dim: color(palette.accent_dim),
|
||||
on_accent: color(palette.on_accent),
|
||||
danger: color(palette.danger),
|
||||
light: palette.light,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a choice into colours.
|
||||
pub fn resolve(appearance: Appearance) -> Self {
|
||||
Self::of(pstr_core::appearance::Palette::resolve(appearance))
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend two colours in linear light, through the shared implementation.
|
||||
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
||||
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
|
||||
color(pstr_core::appearance::mix(rgb(from), rgb(to), t))
|
||||
}
|
||||
|
||||
/// Which way a gradient runs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
@@ -115,6 +270,16 @@ pub fn danger() -> Color32 {
|
||||
palette().danger
|
||||
}
|
||||
|
||||
/// Whether ramps are drawn at all.
|
||||
///
|
||||
/// Wider than [`gradients`], which also asks whether the *accent* has two hues
|
||||
/// to run between. The viewer who turns gradients off is usually on a panel that
|
||||
/// bands them into stripes, and that applies to every ramp in the app, not only
|
||||
/// the accent's — so the bar fade and the title backdrop ask this one.
|
||||
pub fn ramps_on() -> bool {
|
||||
ACTIVE.read().gradients
|
||||
}
|
||||
|
||||
/// Whether the accent is currently drawn as a gradient.
|
||||
///
|
||||
/// True only when the viewer has gradients on *and* the accent is a pair of
|
||||
@@ -142,6 +307,29 @@ pub fn accent_fill(
|
||||
veil(painter, rect, corner_radius, lift);
|
||||
}
|
||||
|
||||
/// The accent as a shape rather than paint, for the one caller that has to
|
||||
/// draw it *behind* something already laid out: the pill that slides between
|
||||
/// the tabs in the navigation bar, whose position is only known once every tab
|
||||
/// has been placed.
|
||||
pub fn accent_shape(
|
||||
ctx: &egui::Context,
|
||||
rect: Rect,
|
||||
corner_radius: impl Into<CornerRadius>,
|
||||
) -> egui::Shape {
|
||||
let corner_radius = corner_radius.into();
|
||||
let palette = palette();
|
||||
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
|
||||
return egui::epaint::RectShape::filled(rect, corner_radius, palette.accent).into();
|
||||
}
|
||||
let texture = ramp(
|
||||
ctx,
|
||||
palette.accent,
|
||||
palette.accent_alt,
|
||||
Direction::Horizontal,
|
||||
);
|
||||
textured(rect, corner_radius, &texture)
|
||||
}
|
||||
|
||||
/// The same, for a palette that is not the one on screen: the accent swatches
|
||||
/// on the settings page, each of which previews the theme clicking it would
|
||||
/// give.
|
||||
@@ -170,6 +358,27 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
|
||||
painter.add(textured(rect, corner_radius, &texture));
|
||||
}
|
||||
|
||||
/// A ramp from `color` to nothing, for laying artwork into the page.
|
||||
///
|
||||
/// The other ramps here run between two opaque colours; this one runs down its
|
||||
/// own alpha, so what is under it shows through the far end. That is what makes
|
||||
/// a backdrop end in the page rather than in a horizontal line across the
|
||||
/// window.
|
||||
/// `arriving` is which end of `direction` the colour is solid at: `false` for a
|
||||
/// scrim that starts opaque and lets the picture out, `true` for one that lets
|
||||
/// the picture *into* the page — which is the one the title backdrop wants,
|
||||
/// since the page is below the band.
|
||||
pub fn fade_shape(
|
||||
ctx: &egui::Context,
|
||||
rect: Rect,
|
||||
color: Color32,
|
||||
direction: Direction,
|
||||
arriving: bool,
|
||||
) -> egui::Shape {
|
||||
let texture = alpha_ramp(ctx, color, direction, arriving);
|
||||
textured(rect, CornerRadius::ZERO, &texture)
|
||||
}
|
||||
|
||||
/// The one gradient that is not the accent: the top bar, which runs from the
|
||||
/// surface colour down into the page so that the join between them is a fade
|
||||
/// rather than an edge.
|
||||
@@ -194,6 +403,56 @@ pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape {
|
||||
textured(rect, CornerRadius::ZERO, &texture)
|
||||
}
|
||||
|
||||
/// The shadow under a raised tile, at `strength` of its full depth.
|
||||
///
|
||||
/// Depth is the one separator this app has been doing without: everything is
|
||||
/// told apart by fill colour, which works until two things that should be at
|
||||
/// different heights are the same colour. Kept soft and offset downwards —
|
||||
/// a shadow you can see the edge of reads as a border.
|
||||
///
|
||||
/// Scaled rather than switched on, so it can ride the same hover value as the
|
||||
/// border and arrive with it.
|
||||
pub fn tile_shadow(strength: f32) -> egui::epaint::Shadow {
|
||||
shadow(strength, [0, 6], 16, 90)
|
||||
}
|
||||
|
||||
/// The shadow a bar casts onto the page: shallower than a tile's, because a bar
|
||||
/// is not floating over the page so much as sitting against it.
|
||||
///
|
||||
/// `cast_down` for the bar at the top of the window, off for the transport bar
|
||||
/// at the bottom — a shadow that falls the same way on both would light the
|
||||
/// window from two directions at once.
|
||||
pub fn bar_shadow(cast_down: bool) -> egui::epaint::Shadow {
|
||||
shadow(1.0, [0, if cast_down { 4 } else { -4 }], 12, 70)
|
||||
}
|
||||
|
||||
fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::Shadow {
|
||||
let strength = strength.clamp(0.0, 1.0);
|
||||
egui::epaint::Shadow {
|
||||
offset,
|
||||
blur,
|
||||
spread: 0,
|
||||
// A light theme cannot carry the same shadow: the same black over a
|
||||
// near-white page is a smudge, where over a dark one it is barely
|
||||
// visible. Half strength on light, which is roughly where the two read
|
||||
// the same.
|
||||
color: Color32::from_black_alpha(
|
||||
(f32::from(alpha) * strength * if palette().light { 0.5 } else { 1.0 }) as u8,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover and press film on its own, for a control whose fill was painted by
|
||||
/// somebody else — the selected tab, which sits on the pill.
|
||||
pub fn veil_over(
|
||||
painter: &egui::Painter,
|
||||
rect: Rect,
|
||||
corner_radius: impl Into<CornerRadius>,
|
||||
lift: f32,
|
||||
) {
|
||||
veil(painter, rect, corner_radius.into(), lift);
|
||||
}
|
||||
|
||||
/// A white or black film over `rect`, for hover and press states. A no-op at
|
||||
/// zero, which is the case that matters — most controls are at rest.
|
||||
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
|
||||
@@ -296,13 +555,14 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
||||
style.spacing.interact_size.y = 30.0;
|
||||
style.spacing.scroll.bar_width = 10.0;
|
||||
|
||||
use egui::FontFamily::Proportional;
|
||||
// egui's own style names, pointed at the ramp above so that a stock
|
||||
// widget nobody restyled still lands on it.
|
||||
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
|
||||
style.text_styles = [
|
||||
(Heading, egui::FontId::new(26.0, Proportional)),
|
||||
(Body, egui::FontId::new(14.0, Proportional)),
|
||||
(Button, egui::FontId::new(14.0, Proportional)),
|
||||
(Small, egui::FontId::new(11.5, Proportional)),
|
||||
(Heading, Role::Display.font()),
|
||||
(Body, Role::Body.font()),
|
||||
(Button, Role::Body.font()),
|
||||
(Small, Role::Caption.font()),
|
||||
(
|
||||
Monospace,
|
||||
egui::FontId::new(12.5, egui::FontFamily::Monospace),
|
||||
@@ -335,7 +595,10 @@ struct Active {
|
||||
/// flavour the window is not wearing are eight ramps that belong to no
|
||||
/// palette in particular. Bounded by the number of flavours times the
|
||||
/// number of accents, and only the ones actually drawn.
|
||||
ramps: BTreeMap<(u32, u32, bool), TextureHandle>,
|
||||
/// The last field separates a ramp between two colours from one that runs
|
||||
/// down its own alpha — the two would otherwise collide on
|
||||
/// `(colour, transparent)`.
|
||||
ramps: BTreeMap<(u32, u32, bool, u8), TextureHandle>,
|
||||
}
|
||||
|
||||
static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
||||
@@ -346,17 +609,54 @@ static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
||||
|
||||
/// A ramp between two colours, built once and kept.
|
||||
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
|
||||
build(ctx, from, to, direction, 0, mix)
|
||||
}
|
||||
|
||||
/// A ramp from `color` down to nothing.
|
||||
///
|
||||
/// The alpha is what is interpolated; the colour stays put. Fading the colour
|
||||
/// as well would take a dark scrim through grey on its way out, which over
|
||||
/// artwork reads as fog rather than as the picture ending.
|
||||
fn alpha_ramp(
|
||||
ctx: &egui::Context,
|
||||
color: Color32,
|
||||
direction: Direction,
|
||||
arriving: bool,
|
||||
) -> TextureHandle {
|
||||
build(
|
||||
ctx,
|
||||
color,
|
||||
Color32::TRANSPARENT,
|
||||
direction,
|
||||
1 + u8::from(arriving),
|
||||
move |from, _, t| from.gamma_multiply(if arriving { t } else { 1.0 - t }),
|
||||
)
|
||||
}
|
||||
|
||||
/// One texture per ramp, built on demand and kept until the palette changes.
|
||||
///
|
||||
/// `kind` says which of the three kinds this is, so that a fade and a colour
|
||||
/// ramp between the same two ends do not come out sharing one texture.
|
||||
fn build(
|
||||
ctx: &egui::Context,
|
||||
from: Color32,
|
||||
to: Color32,
|
||||
direction: Direction,
|
||||
kind: u8,
|
||||
stop: impl Fn(Color32, Color32, f32) -> Color32,
|
||||
) -> TextureHandle {
|
||||
let key = (
|
||||
u32::from_le_bytes(from.to_array()),
|
||||
u32::from_le_bytes(to.to_array()),
|
||||
direction == Direction::Vertical,
|
||||
kind,
|
||||
);
|
||||
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
|
||||
return texture.clone();
|
||||
}
|
||||
|
||||
let pixels: Vec<Color32> = (0..RAMP_STOPS)
|
||||
.map(|stop| mix(from, to, stop as f32 / (RAMP_STOPS - 1) as f32))
|
||||
.map(|index| stop(from, to, index as f32 / (RAMP_STOPS - 1) as f32))
|
||||
.collect();
|
||||
let size = match direction {
|
||||
Direction::Horizontal => [RAMP_STOPS, 1],
|
||||
@@ -382,353 +682,10 @@ fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -
|
||||
/// light, which is what this does.
|
||||
const RAMP_STOPS: usize = 64;
|
||||
|
||||
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
|
||||
/// ramp between saturated hues, which is exactly where a gradient is looked at.
|
||||
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
||||
let from = Rgba::from(from);
|
||||
let to = Rgba::from(to);
|
||||
Color32::from(from * (1.0 - t) + to * t)
|
||||
}
|
||||
|
||||
/// Relative luminance, in linear light, as WCAG defines it.
|
||||
fn luminance(color: Color32) -> f32 {
|
||||
let rgba = Rgba::from(color);
|
||||
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
|
||||
}
|
||||
|
||||
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
||||
/// 21.0 for black against white.
|
||||
fn contrast(one: Color32, other: Color32) -> f32 {
|
||||
let (one, other) = (luminance(one), luminance(other));
|
||||
(one.max(other) + 0.05) / (one.min(other) + 0.05)
|
||||
}
|
||||
|
||||
/// The least contrast a label gets anywhere along a gradient between two
|
||||
/// colours. Sampled rather than solved: the worst point is not always an end,
|
||||
/// because the ink can be brighter than one end and darker than the other.
|
||||
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
|
||||
(0..=4)
|
||||
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
}
|
||||
|
||||
/// The least contrast the app will accept between a label and the fill under
|
||||
/// it: WCAG AA for large text, which is what wears the accent — button labels,
|
||||
/// a tab, a play glyph, never body copy.
|
||||
const MIN_CONTRAST: f32 = 3.0;
|
||||
|
||||
/// One flavour's raw colours, named as Catppuccin names them.
|
||||
///
|
||||
/// Stored rather than computed: these are somebody else's palette, and the
|
||||
/// point of using it is to use it exactly.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Ramp {
|
||||
base: Color32,
|
||||
mantle: Color32,
|
||||
crust: Color32,
|
||||
surface0: Color32,
|
||||
surface1: Color32,
|
||||
subtext0: Color32,
|
||||
text: Color32,
|
||||
pink: Color32,
|
||||
mauve: Color32,
|
||||
sky: Color32,
|
||||
sapphire: Color32,
|
||||
blue: Color32,
|
||||
lavender: Color32,
|
||||
teal: Color32,
|
||||
green: Color32,
|
||||
peach: Color32,
|
||||
yellow: Color32,
|
||||
red: Color32,
|
||||
light: bool,
|
||||
}
|
||||
|
||||
const fn rgb(hex: u32) -> Color32 {
|
||||
Color32::from_rgb(
|
||||
((hex >> 16) & 0xff) as u8,
|
||||
((hex >> 8) & 0xff) as u8,
|
||||
(hex & 0xff) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// The palette this app shipped with, extended to the hues an accent can be.
|
||||
///
|
||||
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
|
||||
/// chosen to sit on a near-black base at roughly the saturation that one does,
|
||||
/// which is a good deal hotter than any Catppuccin flavour.
|
||||
const PROTON: Ramp = Ramp {
|
||||
base: rgb(0x0e0e12),
|
||||
mantle: rgb(0x17171d),
|
||||
crust: rgb(0x0a0a0d),
|
||||
surface0: rgb(0x1e1e26),
|
||||
surface1: rgb(0x2a2a35),
|
||||
subtext0: rgb(0x8e8e9c),
|
||||
text: rgb(0xeaeaf0),
|
||||
pink: rgb(0xff5fbe),
|
||||
mauve: rgb(0x7d4dff),
|
||||
sky: rgb(0x4dd2ff),
|
||||
sapphire: rgb(0x3a8fc4),
|
||||
blue: rgb(0x3f6fe0),
|
||||
lavender: rgb(0xa68cff),
|
||||
teal: rgb(0x3fd6b8),
|
||||
green: rgb(0x56d364),
|
||||
peach: rgb(0xff9a4d),
|
||||
yellow: rgb(0xffd166),
|
||||
red: rgb(0xe05561),
|
||||
light: false,
|
||||
};
|
||||
|
||||
const LATTE: Ramp = Ramp {
|
||||
base: rgb(0xeff1f5),
|
||||
mantle: rgb(0xe6e9ef),
|
||||
crust: rgb(0xdce0e8),
|
||||
surface0: rgb(0xccd0da),
|
||||
surface1: rgb(0xbcc0cc),
|
||||
subtext0: rgb(0x6c6f85),
|
||||
text: rgb(0x4c4f69),
|
||||
pink: rgb(0xea76cb),
|
||||
mauve: rgb(0x8839ef),
|
||||
sky: rgb(0x04a5e5),
|
||||
sapphire: rgb(0x209fb5),
|
||||
blue: rgb(0x1e66f5),
|
||||
lavender: rgb(0x7287fd),
|
||||
teal: rgb(0x179299),
|
||||
green: rgb(0x40a02b),
|
||||
peach: rgb(0xfe640b),
|
||||
yellow: rgb(0xdf8e1d),
|
||||
red: rgb(0xd20f39),
|
||||
light: true,
|
||||
};
|
||||
|
||||
const FRAPPE: Ramp = Ramp {
|
||||
base: rgb(0x303446),
|
||||
mantle: rgb(0x292c3c),
|
||||
crust: rgb(0x232634),
|
||||
surface0: rgb(0x414559),
|
||||
surface1: rgb(0x51576d),
|
||||
subtext0: rgb(0xa5adce),
|
||||
text: rgb(0xc6d0f5),
|
||||
pink: rgb(0xf4b8e4),
|
||||
mauve: rgb(0xca9ee6),
|
||||
sky: rgb(0x99d1db),
|
||||
sapphire: rgb(0x85c1dc),
|
||||
blue: rgb(0x8caaee),
|
||||
lavender: rgb(0xbabbf1),
|
||||
teal: rgb(0x81c8be),
|
||||
green: rgb(0xa6d189),
|
||||
peach: rgb(0xef9f76),
|
||||
yellow: rgb(0xe5c890),
|
||||
red: rgb(0xe78284),
|
||||
light: false,
|
||||
};
|
||||
|
||||
const MACCHIATO: Ramp = Ramp {
|
||||
base: rgb(0x24273a),
|
||||
mantle: rgb(0x1e2030),
|
||||
crust: rgb(0x181926),
|
||||
surface0: rgb(0x363a4f),
|
||||
surface1: rgb(0x494d64),
|
||||
subtext0: rgb(0xa5adcb),
|
||||
text: rgb(0xcad3f5),
|
||||
pink: rgb(0xf5bde6),
|
||||
mauve: rgb(0xc6a0f6),
|
||||
sky: rgb(0x91d7e3),
|
||||
sapphire: rgb(0x7dc4e4),
|
||||
blue: rgb(0x8aadf4),
|
||||
lavender: rgb(0xb7bdf8),
|
||||
teal: rgb(0x8bd5ca),
|
||||
green: rgb(0xa6da95),
|
||||
peach: rgb(0xf5a97f),
|
||||
yellow: rgb(0xeed49f),
|
||||
red: rgb(0xed8796),
|
||||
light: false,
|
||||
};
|
||||
|
||||
const MOCHA: Ramp = Ramp {
|
||||
base: rgb(0x1e1e2e),
|
||||
mantle: rgb(0x181825),
|
||||
crust: rgb(0x11111b),
|
||||
surface0: rgb(0x313244),
|
||||
surface1: rgb(0x45475a),
|
||||
subtext0: rgb(0xa6adc8),
|
||||
text: rgb(0xcdd6f4),
|
||||
pink: rgb(0xf5c2e7),
|
||||
mauve: rgb(0xcba6f7),
|
||||
sky: rgb(0x89dceb),
|
||||
sapphire: rgb(0x74c7ec),
|
||||
blue: rgb(0x89b4fa),
|
||||
lavender: rgb(0xb4befe),
|
||||
teal: rgb(0x94e2d5),
|
||||
green: rgb(0xa6e3a1),
|
||||
peach: rgb(0xfab387),
|
||||
yellow: rgb(0xf9e2af),
|
||||
red: rgb(0xf38ba8),
|
||||
light: false,
|
||||
};
|
||||
|
||||
impl Ramp {
|
||||
const fn of(flavor: Flavor) -> Self {
|
||||
match flavor {
|
||||
Flavor::Proton => PROTON,
|
||||
Flavor::Latte => LATTE,
|
||||
Flavor::Frappe => FRAPPE,
|
||||
Flavor::Macchiato => MACCHIATO,
|
||||
Flavor::Mocha => MOCHA,
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent, and the hue a gradient in it runs into.
|
||||
///
|
||||
/// The partners are neighbours on the wheel rather than complements: a
|
||||
/// gradient across half the spectrum passes through a colour that belongs
|
||||
/// to neither end, and on a seek bar that reads as a bug. The exception is
|
||||
/// [`Accent::PinkSky`], which is the whole point of that entry.
|
||||
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
|
||||
match accent {
|
||||
Accent::Mauve => (self.mauve, self.blue),
|
||||
Accent::Pink => (self.pink, self.mauve),
|
||||
Accent::Sky => (self.sky, self.sapphire),
|
||||
Accent::PinkSky => (self.pink, self.sky),
|
||||
Accent::Lavender => (self.lavender, self.blue),
|
||||
Accent::Blue => (self.blue, self.sapphire),
|
||||
Accent::Teal => (self.teal, self.green),
|
||||
Accent::Peach => (self.peach, self.yellow),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The default, spelled out as a constant so the global has something to
|
||||
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
|
||||
/// panic message painted before the engine exists, still gets colours.
|
||||
const PROTON: Self = Self {
|
||||
background: PROTON.base,
|
||||
surface: PROTON.mantle,
|
||||
sunken: PROTON.crust,
|
||||
card: PROTON.surface0,
|
||||
card_hover: PROTON.surface1,
|
||||
border: rgb(0x262630),
|
||||
text: PROTON.text,
|
||||
muted: PROTON.subtext0,
|
||||
accent: PROTON.mauve,
|
||||
accent_alt: PROTON.blue,
|
||||
accent_dim: rgb(0x5333ad),
|
||||
on_accent: PROTON.text,
|
||||
danger: PROTON.red,
|
||||
light: false,
|
||||
};
|
||||
|
||||
/// Resolve a choice into colours.
|
||||
pub fn resolve(appearance: Appearance) -> Self {
|
||||
let ramp = Ramp::of(appearance.flavor);
|
||||
let (accent, partner) = ramp.accents(appearance.accent);
|
||||
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
|
||||
// at body-text weight — which makes them mid-luminance, and a
|
||||
// mid-luminance fill is one that neither black nor white sits on. They
|
||||
// are taken down in value here, and only here: the hue and the
|
||||
// saturation are Latte's, so it still looks like Latte, and a label on
|
||||
// a button is legible.
|
||||
let deepen = |color: Color32| {
|
||||
if ramp.light {
|
||||
mix(color, Color32::BLACK, 0.30)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
};
|
||||
let accent = deepen(accent);
|
||||
let accent_alt = if appearance.gradients {
|
||||
deepen(partner)
|
||||
} else {
|
||||
accent
|
||||
};
|
||||
|
||||
// Ink that survives on the accent. The flavour's own reading first —
|
||||
// pale text on a dark theme — and the opposite when that does not clear
|
||||
// the bar, which on Catppuccin's dark flavours is most of the time:
|
||||
// their accents are *pastel*, and white on #f5c2e7 is not a label.
|
||||
//
|
||||
// Latte's darkest colour is its body text, and even that is only #4c4f69,
|
||||
// so the dark side is taken a third of the way to black.
|
||||
let ink_dark = if ramp.light {
|
||||
mix(ramp.text, Color32::BLACK, 0.35)
|
||||
} else {
|
||||
ramp.crust
|
||||
};
|
||||
let ink_light = if ramp.light { ramp.base } else { ramp.text };
|
||||
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
|
||||
ink_light
|
||||
} else {
|
||||
ink_dark
|
||||
};
|
||||
|
||||
Self {
|
||||
background: ramp.base,
|
||||
surface: ramp.mantle,
|
||||
sunken: ramp.crust,
|
||||
card: ramp.surface0,
|
||||
card_hover: ramp.surface1,
|
||||
border: ramp.surface1,
|
||||
text: ramp.text,
|
||||
muted: ramp.subtext0,
|
||||
accent,
|
||||
accent_alt,
|
||||
// Behind text, so it is the accent taken most of the way back to
|
||||
// the page: a selection in a saturated hue is a selection nobody
|
||||
// can read through.
|
||||
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
|
||||
on_accent,
|
||||
danger: ramp.red,
|
||||
light: ramp.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_flavour_and_accent_resolves_to_readable_ink() {
|
||||
for flavor in Flavor::ALL {
|
||||
for accent in Accent::ALL {
|
||||
let palette = Palette::resolve(Appearance {
|
||||
flavor,
|
||||
accent,
|
||||
gradients: true,
|
||||
});
|
||||
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
||||
assert!(
|
||||
ratio >= MIN_CONTRAST,
|
||||
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_light_flavour_reports_itself_light() {
|
||||
for flavor in Flavor::ALL {
|
||||
let palette = Palette::resolve(Appearance {
|
||||
flavor,
|
||||
..Appearance::default()
|
||||
});
|
||||
assert_eq!(palette.light, flavor.is_light());
|
||||
// And the page is on the right side of the middle either way.
|
||||
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_gradients_off_leaves_one_colour_to_draw() {
|
||||
let flat = Palette::resolve(Appearance {
|
||||
accent: Accent::PinkSky,
|
||||
gradients: false,
|
||||
..Appearance::default()
|
||||
});
|
||||
assert_eq!(flat.accent, flat.accent_alt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
||||
let resolved = Palette::resolve(Appearance::default());
|
||||
@@ -740,11 +697,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_ramp_runs_from_one_colour_to_the_other() {
|
||||
// The blend itself is `pstr_core::appearance`'s and tested there; what
|
||||
// is checked here is that the conversion in and out of `Color32` is
|
||||
// not lossy at the ends.
|
||||
let from = Color32::from_rgb(0, 0, 0);
|
||||
let to = Color32::from_rgb(255, 255, 255);
|
||||
assert_eq!(mix(from, to, 0.0), from);
|
||||
assert_eq!(mix(from, to, 1.0), to);
|
||||
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
|
||||
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
|
||||
assert_ne!(mix(from, to, 0.5), from);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Inspectable desktop download queue.
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::engine::{DownloadItem, DownloadState};
|
||||
use crate::{theme, ui};
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
|
||||
ui.heading("Downloads");
|
||||
ui.label(ui::muted(
|
||||
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
|
||||
));
|
||||
ui.add_space(12.0);
|
||||
|
||||
if downloads.is_empty() {
|
||||
ui.label("No offline downloads.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
while start < downloads.len() {
|
||||
let title_key = &downloads[start].target.title_key;
|
||||
let title = &downloads[start].target.title_name;
|
||||
let end = downloads[start..]
|
||||
.iter()
|
||||
.position(|item| item.target.title_key != *title_key)
|
||||
.map_or(downloads.len(), |offset| start + offset);
|
||||
let group = &downloads[start..end];
|
||||
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
|
||||
let total: u64 = group.iter().map(|item| item.total).sum();
|
||||
let completed = group
|
||||
.iter()
|
||||
.filter(|item| item.state == DownloadState::Completed)
|
||||
.count();
|
||||
|
||||
ui.label(theme::Role::Section.rich(title).strong());
|
||||
let totals_known = group.iter().all(|item| item.total > 0);
|
||||
ui.horizontal(|ui| {
|
||||
if totals_known {
|
||||
ui.add(
|
||||
egui::ProgressBar::new(fraction(downloaded, total))
|
||||
.desired_width(260.0)
|
||||
.show_percentage(),
|
||||
);
|
||||
} else {
|
||||
ui.add(egui::Spinner::new().size(16.0));
|
||||
ui.label(ui::muted("Calculating size…"));
|
||||
}
|
||||
ui.label(ui::muted(format!(
|
||||
"{} / {} · {completed}/{} files",
|
||||
bytes(downloaded),
|
||||
if !totals_known {
|
||||
"calculating…".to_owned()
|
||||
} else {
|
||||
bytes(total)
|
||||
},
|
||||
group.len()
|
||||
)));
|
||||
});
|
||||
for item in group {
|
||||
row(ui, item, actions);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(6))
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let caption = if item.target.subtitle.is_empty() {
|
||||
item.target.name.as_str()
|
||||
} else {
|
||||
item.target.subtitle.as_str()
|
||||
};
|
||||
ui.add_sized(
|
||||
[180.0, 18.0],
|
||||
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
|
||||
)
|
||||
.on_hover_text(&item.target.name);
|
||||
|
||||
let status = match &item.state {
|
||||
DownloadState::Queued => "Queued".to_owned(),
|
||||
DownloadState::Running => "Downloading".to_owned(),
|
||||
DownloadState::Paused => "Paused".to_owned(),
|
||||
DownloadState::Completed => "Available offline".to_owned(),
|
||||
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
|
||||
DownloadState::Failed(error) => format!("Failed: {error}"),
|
||||
};
|
||||
ui.add_sized(
|
||||
[240.0, 18.0],
|
||||
egui::Label::new(ui::muted(status)).truncate(),
|
||||
);
|
||||
|
||||
if item.total > 0 {
|
||||
ui.add(
|
||||
egui::ProgressBar::new(item.percent())
|
||||
.desired_width(140.0)
|
||||
.show_percentage(),
|
||||
);
|
||||
ui.label(ui::muted(format!(
|
||||
"{} / {}",
|
||||
bytes(item.downloaded),
|
||||
bytes(item.total)
|
||||
)));
|
||||
}
|
||||
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| match item.state {
|
||||
DownloadState::Queued | DownloadState::Running => {
|
||||
if ui
|
||||
.button("Cancel")
|
||||
.on_hover_text("Stop and keep partial")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::CancelDownload(item.key.clone()));
|
||||
}
|
||||
if ui.button("Pause").clicked() {
|
||||
actions.push(Action::PauseDownload(item.key.clone()));
|
||||
}
|
||||
}
|
||||
DownloadState::Paused => {
|
||||
if ui
|
||||
.button("Cancel")
|
||||
.on_hover_text("Stop and keep partial")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::CancelDownload(item.key.clone()));
|
||||
}
|
||||
if ui.button("Resume").clicked() {
|
||||
actions.push(Action::ResumeDownload(item.key.clone()));
|
||||
}
|
||||
}
|
||||
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
||||
if ui.button("Delete partial…").clicked() {
|
||||
actions.push(Action::RemoveDownload(item.key.clone(), true));
|
||||
}
|
||||
if ui.button("Resume / Retry").clicked() {
|
||||
actions.push(Action::ResumeDownload(item.key.clone()));
|
||||
}
|
||||
}
|
||||
DownloadState::Completed => {
|
||||
if ui
|
||||
.button("Delete")
|
||||
.on_hover_text("Delete the offline copy; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(item.key.clone(), false));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn fraction(downloaded: u64, total: u64) -> f32 {
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(downloaded as f64 / total as f64).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(value: u64) -> String {
|
||||
const KIB: f64 = 1024.0;
|
||||
const MIB: f64 = 1024.0 * KIB;
|
||||
const GIB: f64 = 1024.0 * MIB;
|
||||
let value = value as f64;
|
||||
if value >= GIB {
|
||||
format!("{:.1} GiB", value / GIB)
|
||||
} else if value >= MIB {
|
||||
format!("{:.1} MiB", value / MIB)
|
||||
} else if value >= KIB {
|
||||
format!("{:.1} KiB", value / KIB)
|
||||
} else {
|
||||
format!("{value:.0} B")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,9 @@ fn continue_row(
|
||||
subtitle: remaining,
|
||||
progress: episode.progress().map(|value| value as f32),
|
||||
badge: episode.numbering(),
|
||||
// A shelf that scrolls sideways has no right edge
|
||||
// to reach, so nothing to flex to.
|
||||
width: theme::CARD_WIDTH,
|
||||
},
|
||||
)
|
||||
.clicked();
|
||||
@@ -107,9 +110,14 @@ fn continue_row(
|
||||
|
||||
/// Every title, wrapped to the window.
|
||||
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
||||
let columns = ui::columns(ui.available_width());
|
||||
for row in titles.chunks(columns) {
|
||||
let grid = ui::columns(ui.available_width());
|
||||
for row in titles.chunks(grid.columns) {
|
||||
ui.horizontal(|ui| {
|
||||
// The gap the width was divided around, so what is drawn matches
|
||||
// what was measured. egui's default item spacing is narrower, and
|
||||
// the difference times the column count is a visible drift towards
|
||||
// the left edge.
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for title in row {
|
||||
let clicked = ui::card(
|
||||
ui,
|
||||
@@ -121,6 +129,7 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
||||
// What a film is says itself in the subtitle below the
|
||||
// card; a second `Film` over the poster is noise.
|
||||
badge: None,
|
||||
width: grid.width,
|
||||
},
|
||||
)
|
||||
.clicked();
|
||||
@@ -163,8 +172,8 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(120.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Nothing in the library yet")
|
||||
.size(20.0)
|
||||
theme::Role::Title
|
||||
.rich("Nothing in the library yet")
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -49,8 +49,8 @@ pub fn show(
|
||||
ui.set_width(620.0);
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Match “{}”", matcher.title_name))
|
||||
.size(18.0)
|
||||
theme::Role::Heading
|
||||
.rich(format!("Match “{}”", matcher.title_name))
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
);
|
||||
@@ -66,7 +66,7 @@ pub fn show(
|
||||
ui.add_space(10.0);
|
||||
|
||||
if let Some(error) = &matcher.error {
|
||||
ui.label(egui::RichText::new(error).size(12.0).color(theme::danger()));
|
||||
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
@@ -212,8 +212,8 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(&found.name)
|
||||
.size(14.0)
|
||||
theme::Role::Body
|
||||
.rich(&found.name)
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
);
|
||||
@@ -224,7 +224,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
||||
if let Some(overview) = &found.overview {
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
overview.replace('\n', " "),
|
||||
egui::FontId::proportional(11.5),
|
||||
theme::Role::Caption.font(),
|
||||
theme::muted(),
|
||||
ui.available_width(),
|
||||
);
|
||||
|
||||
+243
-69
@@ -3,9 +3,11 @@
|
||||
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
||||
//! applies them after the frame is drawn.
|
||||
|
||||
pub mod downloads;
|
||||
pub mod library;
|
||||
pub mod matcher;
|
||||
pub mod player;
|
||||
pub mod settings;
|
||||
pub mod shares;
|
||||
pub mod title;
|
||||
pub mod transport;
|
||||
@@ -113,8 +115,8 @@ pub fn format_size(bytes: i64) -> String {
|
||||
pub fn section(ui: &mut egui::Ui, text: &str) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
egui::RichText::new(text)
|
||||
.size(17.0)
|
||||
theme::Role::Section
|
||||
.rich(text)
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
);
|
||||
@@ -123,18 +125,81 @@ pub fn section(ui: &mut egui::Ui, text: &str) {
|
||||
|
||||
/// Small grey text, for everything that is context rather than content.
|
||||
pub fn muted(text: impl Into<String>) -> egui::RichText {
|
||||
egui::RichText::new(text).size(12.0).color(theme::muted())
|
||||
theme::Role::Caption.rich(text).color(theme::muted())
|
||||
}
|
||||
|
||||
/// The one button style that means "this is the action".
|
||||
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
filled(ui, text, true, Vec2::new(0.0, 0.0))
|
||||
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
||||
}
|
||||
|
||||
/// A tab in the top bar. The selected one wears the accent; the others are text
|
||||
/// until the pointer is over them.
|
||||
pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
||||
filled(ui, text, selected, Vec2::new(4.0, 0.0))
|
||||
/// What [`filled`] paints behind its label.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Fill {
|
||||
/// The accent, gradient and all.
|
||||
Accent,
|
||||
/// Nothing — something else already painted the accent here, and the label
|
||||
/// only has to be legible on it.
|
||||
Ink,
|
||||
/// Nothing at rest, the hover colour under the pointer.
|
||||
None,
|
||||
}
|
||||
|
||||
/// A row of tabs with one accent pill that slides between them.
|
||||
///
|
||||
/// Drawn as a row rather than a tab at a time because the pill has to be
|
||||
/// painted *behind* labels whose positions are not known until they have been
|
||||
/// laid out, and because a pill that moves has to be one pill: a per-tab fill
|
||||
/// can only ever cross-fade, which reads as two things blinking rather than one
|
||||
/// thing travelling.
|
||||
///
|
||||
/// Returns the index that was clicked, if any.
|
||||
pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<usize> {
|
||||
// Reserved now, filled in below: everything drawn after this lands on top
|
||||
// of it whatever it turns out to be.
|
||||
let pill = ui.painter().add(egui::Shape::Noop);
|
||||
|
||||
let mut clicked = None;
|
||||
let mut selected = None;
|
||||
for (index, (text, is_selected)) in items.iter().enumerate() {
|
||||
let response = tab(ui, *is_selected, text);
|
||||
if *is_selected {
|
||||
selected = Some(response.rect);
|
||||
}
|
||||
if response.clicked() {
|
||||
clicked = Some(index);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(target) = selected {
|
||||
// Interpolating the edges rather than the centre and the width keeps
|
||||
// the pill from overshooting when it moves between tabs of different
|
||||
// lengths — both edges arrive at the same time.
|
||||
let ctx = ui.ctx();
|
||||
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16);
|
||||
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16);
|
||||
let rect = Rect::from_min_max(
|
||||
egui::pos2(left, target.top()),
|
||||
egui::pos2(right, target.bottom()),
|
||||
);
|
||||
ui.painter().set(
|
||||
pill,
|
||||
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)),
|
||||
);
|
||||
}
|
||||
|
||||
clicked
|
||||
}
|
||||
|
||||
/// One tab. The selected one is ink on the pill [`tabs`] draws; the others are
|
||||
/// text until the pointer is over them.
|
||||
fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
||||
filled(
|
||||
ui,
|
||||
text,
|
||||
if selected { Fill::Ink } else { Fill::None },
|
||||
Vec2::new(4.0, 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// A button painted by hand, so that "filled with the accent" can mean a
|
||||
@@ -143,7 +208,7 @@ pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response {
|
||||
/// `egui::Button` takes a single `Color32` and there is no way in to give it
|
||||
/// anything else, so the fill, the label and the hover states are all drawn
|
||||
/// here. Everything else — sizing, padding, the click — is still egui's.
|
||||
fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::Response {
|
||||
fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Response {
|
||||
let padding = ui.spacing().button_padding + extra;
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
text.to_owned(),
|
||||
@@ -161,23 +226,35 @@ fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::R
|
||||
}
|
||||
|
||||
let radius = CornerRadius::same(8);
|
||||
let lift = if response.is_pointer_button_down_on() {
|
||||
-0.14
|
||||
} else if response.hovered() {
|
||||
0.10
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Eased rather than switched, so the veil arrives over a few frames instead
|
||||
// of appearing whole the moment the pointer crosses the edge. The press is
|
||||
// quicker than the hover: a click should feel like it registered, not like
|
||||
// it was considered.
|
||||
let ctx = ui.ctx().clone();
|
||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
||||
let press = ctx.animate_bool_with_time(
|
||||
response.id.with("press"),
|
||||
response.is_pointer_button_down_on(),
|
||||
0.05,
|
||||
);
|
||||
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
|
||||
|
||||
let ink = if accented {
|
||||
theme::accent_fill(ui.painter(), rect, radius, lift);
|
||||
theme::on_accent()
|
||||
} else {
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
||||
theme::text()
|
||||
} else {
|
||||
theme::muted()
|
||||
let ink = match fill {
|
||||
Fill::Accent => {
|
||||
theme::accent_fill(ui.painter(), rect, radius, lift);
|
||||
theme::on_accent()
|
||||
}
|
||||
// The pill is already under this one; only the veil is missing.
|
||||
Fill::Ink => {
|
||||
theme::veil_over(ui.painter(), rect, radius, lift);
|
||||
theme::on_accent()
|
||||
}
|
||||
Fill::None => {
|
||||
if hover > 0.0 {
|
||||
ui.painter()
|
||||
.rect_filled(rect, radius, theme::card_hover().gamma_multiply(hover));
|
||||
}
|
||||
theme::muted().lerp_to_gamma(theme::text(), hover)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,6 +273,12 @@ pub struct Card<'a> {
|
||||
pub progress: Option<f32>,
|
||||
/// A short label in the corner — `S01E04`.
|
||||
pub badge: Option<String>,
|
||||
/// How wide to draw it.
|
||||
///
|
||||
/// A grid flexes this so its rows reach the right edge of the window; a
|
||||
/// sideways-scrolling shelf has no edge to reach and passes
|
||||
/// [`theme::CARD_WIDTH`].
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
/// Draw one tile and report whether it was clicked.
|
||||
@@ -207,7 +290,7 @@ pub struct Card<'a> {
|
||||
/// the title, usually. The gap it leaves is filled with the card colour, which
|
||||
/// reads as deliberate in a way a stretched poster does not.
|
||||
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
let width = theme::CARD_WIDTH;
|
||||
let width = card.width;
|
||||
let image_height = (width * theme::CARD_ASPECT).round();
|
||||
// Two lines of name plus one of subtitle. Fixed, because the tile is
|
||||
// allocated before the name is laid out — and because a grid whose rows are
|
||||
@@ -222,47 +305,69 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
}
|
||||
|
||||
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
|
||||
let hovered = response.hovered();
|
||||
// Hover is a value, not a flag: the border, the lift and the shadow all
|
||||
// ride it, so they arrive together rather than each snapping on its own.
|
||||
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||
// the frame rate — and it needs no state kept here.
|
||||
let hover =
|
||||
ui.ctx()
|
||||
.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12);
|
||||
// The picture arrives whenever its download finishes, several frames after
|
||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||
// letters popping into stills into one settle.
|
||||
let art_in = ui
|
||||
.ctx()
|
||||
.animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22);
|
||||
let painter = ui.painter();
|
||||
|
||||
if hover > 0.0 {
|
||||
painter.add(theme::tile_shadow(hover).as_shape(image_rect, radius));
|
||||
}
|
||||
painter.rect_filled(image_rect, radius, theme::card());
|
||||
|
||||
// Under the picture, not instead of it: it is what shows through while the
|
||||
// art fades in, and it is the whole tile when there is no art to come.
|
||||
if art_in < 1.0 {
|
||||
painter.text(
|
||||
image_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
initials(card.name),
|
||||
egui::FontId::proportional(28.0),
|
||||
theme::muted().gamma_multiply(1.0 - art_in),
|
||||
);
|
||||
}
|
||||
|
||||
// White because a textured rect *multiplies* its fill by the texture; the
|
||||
// alpha on it is what fades the picture in.
|
||||
let ink = Color32::WHITE.gamma_multiply(art_in);
|
||||
match &card.art {
|
||||
Some((texture, ArtShape::Landscape)) => {
|
||||
let size = texture.size_vec2();
|
||||
painter.image(
|
||||
texture.id(),
|
||||
image_rect,
|
||||
cover_uv(size, image_rect.size()),
|
||||
Color32::WHITE,
|
||||
painter.add(
|
||||
egui::epaint::RectShape::filled(image_rect, radius, ink)
|
||||
.with_texture(texture.id(), cover_uv(size, image_rect.size())),
|
||||
);
|
||||
}
|
||||
Some((texture, ArtShape::Portrait)) => {
|
||||
painter.image(
|
||||
texture.id(),
|
||||
contain_rect(texture.size_vec2(), image_rect),
|
||||
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
painter.text(
|
||||
image_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
initials(card.name),
|
||||
egui::FontId::proportional(28.0),
|
||||
theme::muted(),
|
||||
painter.add(
|
||||
egui::epaint::RectShape::filled(
|
||||
contain_rect(texture.size_vec2(), image_rect),
|
||||
radius,
|
||||
ink,
|
||||
)
|
||||
.with_texture(
|
||||
texture.id(),
|
||||
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
),
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if let Some(badge) = &card.badge {
|
||||
let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0);
|
||||
let galley = painter.layout_no_wrap(
|
||||
badge.clone(),
|
||||
egui::FontId::proportional(11.0),
|
||||
Color32::WHITE,
|
||||
);
|
||||
let galley =
|
||||
painter.layout_no_wrap(badge.clone(), theme::Role::Micro.font(), Color32::WHITE);
|
||||
let background = Rect::from_min_size(
|
||||
anchor - Vec2::new(galley.size().x + 10.0, 0.0),
|
||||
galley.size() + Vec2::new(10.0, 4.0),
|
||||
@@ -276,21 +381,45 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
}
|
||||
|
||||
if let Some(progress) = card.progress {
|
||||
let track = Rect::from_min_size(
|
||||
let clip_rect = Rect::from_min_size(
|
||||
image_rect.left_bottom() - Vec2::new(0.0, 4.0),
|
||||
Vec2::new(width, 4.0),
|
||||
);
|
||||
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
|
||||
let clipped_painter = painter.with_clip_rect(clip_rect);
|
||||
|
||||
let track = Rect::from_min_size(
|
||||
image_rect.left_bottom() - Vec2::new(0.0, 16.0),
|
||||
Vec2::new(width, 16.0),
|
||||
);
|
||||
let track_radius = CornerRadius {
|
||||
nw: 0,
|
||||
ne: 0,
|
||||
sw: 8,
|
||||
se: 8,
|
||||
};
|
||||
clipped_painter.rect_filled(track, track_radius, Color32::from_black_alpha(150));
|
||||
|
||||
let mut played = track;
|
||||
played.set_width(track.width() * progress.clamp(0.0, 1.0));
|
||||
theme::accent_fill(painter, played, CornerRadius::ZERO, 0.0);
|
||||
|
||||
let played_radius = CornerRadius {
|
||||
nw: 0,
|
||||
ne: 0,
|
||||
sw: 8,
|
||||
se: if played.max.x >= track.max.x - 0.1 {
|
||||
8
|
||||
} else {
|
||||
0
|
||||
},
|
||||
};
|
||||
theme::accent_fill(&clipped_painter, played, played_radius, 0.0);
|
||||
}
|
||||
|
||||
if hovered {
|
||||
if hover > 0.0 {
|
||||
painter.rect_stroke(
|
||||
image_rect,
|
||||
radius,
|
||||
Stroke::new(2.0, theme::accent()),
|
||||
Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
@@ -305,12 +434,10 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
// the line below it.
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
card.name.to_string(),
|
||||
egui::FontId::proportional(14.0),
|
||||
if hovered {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
theme::text()
|
||||
},
|
||||
theme::Role::Body.font(),
|
||||
// Towards white as the pointer arrives, so the name lifts with the
|
||||
// border rather than staying put while everything around it moves.
|
||||
theme::text().lerp_to_gamma(Color32::WHITE, hover),
|
||||
width,
|
||||
);
|
||||
job.wrap.max_rows = 2;
|
||||
@@ -322,7 +449,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||
text_rect.min + Vec2::new(0.0, name_height + 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
card.subtitle,
|
||||
egui::FontId::proportional(11.5),
|
||||
theme::Role::Caption.font(),
|
||||
theme::muted(),
|
||||
);
|
||||
|
||||
@@ -368,10 +495,33 @@ fn initials(name: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many cards fit, and the gap that leaves them evenly spread.
|
||||
pub fn columns(available: f32) -> usize {
|
||||
(((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() as usize)
|
||||
.max(1)
|
||||
/// How a grid divides the width it was given.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Grid {
|
||||
pub columns: usize,
|
||||
/// What each card should be drawn at, so the row reaches both edges.
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
/// Fit as many cards as will go, then widen them to use the rest.
|
||||
///
|
||||
/// [`theme::CARD_WIDTH`] is a *minimum*, not the width. Dividing by it and
|
||||
/// flooring — which is what this used to do — leaves up to one whole card of
|
||||
/// 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.
|
||||
pub fn columns(available: f32) -> Grid {
|
||||
let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor()
|
||||
as usize)
|
||||
.max(1);
|
||||
// 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
|
||||
// onto a row of its own. A window too narrow for even one full card gets a
|
||||
// card narrower than the minimum, which is better than one clipped by the
|
||||
// edge.
|
||||
let width = ((available - theme::CARD_GAP * (columns - 1) as f32) / columns as f32)
|
||||
.floor()
|
||||
.max(1.0);
|
||||
Grid { columns, width }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -426,7 +576,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn column_count_never_reaches_zero() {
|
||||
assert_eq!(columns(0.0), 1);
|
||||
assert!(columns(2000.0) > 1);
|
||||
assert_eq!(columns(0.0).columns, 1);
|
||||
assert!(columns(2000.0).columns > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_of_cards_fills_the_width_it_was_given() {
|
||||
// The defect this replaced: floor(available / CARD_WIDTH) cards at a
|
||||
// fixed width leave up to one card's worth of the window empty.
|
||||
for available in [400.0, 813.0, 1280.0, 1920.5, 3440.0] {
|
||||
let grid = columns(available);
|
||||
let used =
|
||||
grid.width * grid.columns as f32 + theme::CARD_GAP * (grid.columns - 1) as f32;
|
||||
assert!(used <= available, "{available}: {used} overflows the row");
|
||||
assert!(
|
||||
available - used < grid.columns as f32 + 1.0,
|
||||
"{available}: {} left over",
|
||||
available - used,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_never_go_below_the_minimum_while_they_still_fit() {
|
||||
for available in [500.0, 900.0, 2400.0] {
|
||||
assert!(columns(available).width >= theme::CARD_WIDTH);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
||||
rect.center() - Vec2::new(0.0, 18.0),
|
||||
Align2::CENTER_CENTER,
|
||||
&playback.target.title_name,
|
||||
egui::FontId::proportional(20.0),
|
||||
theme::Role::Title.font(),
|
||||
theme::text(),
|
||||
);
|
||||
painter.text(
|
||||
@@ -298,7 +298,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
||||
} else {
|
||||
"playing in mpv's own window"
|
||||
},
|
||||
egui::FontId::proportional(13.0),
|
||||
theme::Role::Label.font(),
|
||||
theme::muted(),
|
||||
);
|
||||
}
|
||||
@@ -313,14 +313,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
||||
rect.center() - Vec2::new(0.0, 16.0),
|
||||
Align2::CENTER_CENTER,
|
||||
"Up next",
|
||||
egui::FontId::proportional(14.0),
|
||||
theme::Role::Body.font(),
|
||||
theme::muted(),
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.center() + Vec2::new(0.0, 12.0),
|
||||
Align2::CENTER_CENTER,
|
||||
opening.unwrap_or("opening…"),
|
||||
egui::FontId::proportional(20.0),
|
||||
theme::Role::Title.font(),
|
||||
theme::text(),
|
||||
);
|
||||
|
||||
@@ -361,7 +361,7 @@ fn skip(
|
||||
// recap" are different lengths, and one constant leaves the shorter of them
|
||||
// adrift in a box of its own whitespace, which is what made this read as a
|
||||
// rectangle with some text in it instead of a button.
|
||||
let font = egui::FontId::proportional(14.0);
|
||||
let font = theme::Role::Body.font();
|
||||
let text_width = ui
|
||||
.painter()
|
||||
.layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE)
|
||||
@@ -440,8 +440,8 @@ fn up_next_card(
|
||||
ui.set_width(size.x - 28.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Up next")
|
||||
.size(12.0)
|
||||
theme::Role::Caption
|
||||
.rich("Up next")
|
||||
.strong()
|
||||
.color(Color32::from_white_alpha(200)),
|
||||
);
|
||||
@@ -449,16 +449,16 @@ fn up_next_card(
|
||||
// Rounded up, so a card that says "1s" is never
|
||||
// followed by a second of nothing happening.
|
||||
ui.label(
|
||||
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
|
||||
.size(12.0)
|
||||
theme::Role::Caption
|
||||
.rich(format!("in {}s", card.seconds.ceil() as u32))
|
||||
.color(theme::accent()),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&card.caption)
|
||||
.size(14.0)
|
||||
theme::Role::Body
|
||||
.rich(&card.caption)
|
||||
.strong()
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
@@ -533,7 +533,7 @@ fn chrome(
|
||||
|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(egui::RichText::new("Library").size(13.0))
|
||||
egui::Button::new(theme::Role::Label.rich("Library"))
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.min_size(Vec2::new(96.0, 32.0)),
|
||||
@@ -546,14 +546,14 @@ fn chrome(
|
||||
ui.add_space(14.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(&playback.target.title_name)
|
||||
.size(18.0)
|
||||
theme::Role::Heading
|
||||
.rich(&playback.target.title_name)
|
||||
.strong()
|
||||
.color(Color32::WHITE),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(playback.target.caption())
|
||||
.size(13.0)
|
||||
theme::Role::Label
|
||||
.rich(playback.target.caption())
|
||||
.color(Color32::from_white_alpha(190)),
|
||||
);
|
||||
});
|
||||
@@ -561,7 +561,7 @@ fn chrome(
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(egui::RichText::new("⛶").size(15.0))
|
||||
egui::Button::new(theme::Role::Subhead.rich("⛶"))
|
||||
.fill(Color32::from_black_alpha(160))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.min_size(Vec2::new(38.0, 32.0)),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Settings: how the app looks, how it plays, and what it asks the internet.
|
||||
//!
|
||||
//! Split out of the shares page, where these lived because that was the only
|
||||
//! page with a form on it. Nobody looking for the theme looks under the page
|
||||
//! that manages Proton Drive links, and a page that is half link management and
|
||||
//! half preferences is two pages.
|
||||
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||
|
||||
use crate::app::Action;
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
|
||||
/// The settings that are a single value rather than a form.
|
||||
///
|
||||
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
|
||||
/// they are read-only, and passing them one by one is what pushed this
|
||||
/// function past the argument count anybody can read.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Prefs {
|
||||
pub autoplay: bool,
|
||||
pub appearance: Appearance,
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
prefs: Prefs,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
appearance_form(ui, prefs.appearance, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
playback_form(ui, prefs.autoplay, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
});
|
||||
}
|
||||
|
||||
/// The theme: a flavour, an accent, and whether the accent is a gradient.
|
||||
///
|
||||
/// Every control here repaints the whole window the moment it is clicked —
|
||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||
/// only changed a small square would be asking someone to imagine the result.
|
||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Appearance");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted("Palette"));
|
||||
for flavor in Flavor::ALL {
|
||||
if ui
|
||||
.radio(appearance.flavor == flavor, flavor.label())
|
||||
.on_hover_text(flavor.description())
|
||||
.clicked()
|
||||
&& appearance.flavor != flavor
|
||||
{
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
flavor,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.label(ui::muted("Accent"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for accent in Accent::ALL {
|
||||
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
let mut gradients = appearance.gradients;
|
||||
if ui
|
||||
.checkbox(&mut gradients, "Fade the accent into a second colour")
|
||||
.on_hover_text(
|
||||
"Off draws it flat — better on a panel that bands a gradient into stripes",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
gradients,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One accent, drawn in the colours it would actually produce.
|
||||
///
|
||||
/// Resolved against the *selected* flavour rather than the active palette, so
|
||||
/// the row previews what clicking it would give rather than what is on screen.
|
||||
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
||||
let selected = appearance.accent == accent;
|
||||
let size = egui::Vec2::new(52.0, 26.0);
|
||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
let palette = theme::Palette::resolve(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
});
|
||||
let radius = egui::CornerRadius::same(6);
|
||||
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
||||
if selected {
|
||||
// A ring rather than a tick: the tick would have to be drawn in an
|
||||
// ink that reads on eight different fills.
|
||||
ui.painter().rect_stroke(
|
||||
rect.expand(2.0),
|
||||
egui::CornerRadius::same(8),
|
||||
egui::Stroke::new(2.0, theme::text()),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
}
|
||||
response
|
||||
.on_hover_text(accent.label())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// The handful of playback settings that are not per-file.
|
||||
///
|
||||
/// Volume, audio language and subtitle language are all set from the player
|
||||
/// itself, where a viewer can hear the result — only autoplay has no natural
|
||||
/// home there, because by the time it matters the episode is over.
|
||||
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Playback");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
let mut enabled = autoplay;
|
||||
if ui
|
||||
.checkbox(&mut enabled, "Play the next episode automatically")
|
||||
.on_hover_text("Only when an episode reaches its end, never after a failure")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetAutoplay(enabled));
|
||||
}
|
||||
ui.label(ui::muted(
|
||||
"Volume, audio language and subtitles are set from the player, and remembered \
|
||||
from there.",
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
||||
///
|
||||
/// The privacy note is not decoration and it is not in a tooltip. Turning this
|
||||
/// on sends the titles in someone's library to a third party, which is a thing
|
||||
/// they can only agree to if they are told — so it is stated where the switch
|
||||
/// is, before the switch, in the same size as everything else.
|
||||
fn metadata_form(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
ui::section(ui, "Posters and descriptions");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted(
|
||||
"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 \
|
||||
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.",
|
||||
));
|
||||
ui.add_space(10.0);
|
||||
|
||||
let mut changed = false;
|
||||
let mut enabled = settings.enabled;
|
||||
if ui
|
||||
.checkbox(&mut enabled, "Look up posters and descriptions")
|
||||
.changed()
|
||||
{
|
||||
settings.enabled = enabled;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if settings.enabled {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted("Provider"));
|
||||
for provider in ProviderId::ALL {
|
||||
if ui
|
||||
.radio(settings.provider == provider, provider.label())
|
||||
.on_hover_text(provider.description())
|
||||
.clicked()
|
||||
&& settings.provider != provider
|
||||
{
|
||||
settings.provider = provider;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if settings.provider.needs_api_key() {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: settings.provider,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
));
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui::accent_button(ui, "Match the library").clicked() {
|
||||
actions.push(Action::MatchTitles { force: false });
|
||||
}
|
||||
if ui
|
||||
.button("Match everything again")
|
||||
.on_hover_text("Ask about every title, including ones already matched")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::MatchTitles { force: true });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.add_space(6.0);
|
||||
ui.label(ui::muted(
|
||||
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
||||
already stored.",
|
||||
));
|
||||
}
|
||||
|
||||
if changed {
|
||||
actions.push(Action::SetMetadataConfig(settings.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,33 +7,12 @@
|
||||
//! typed.
|
||||
|
||||
use pstr_core::Share;
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||
|
||||
use crate::app::{Action, ShareForm};
|
||||
use crate::theme;
|
||||
use crate::ui;
|
||||
|
||||
/// The settings on this page that are a single value rather than a form.
|
||||
///
|
||||
/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together,
|
||||
/// they are read-only, and passing them one by one is what pushed this
|
||||
/// function past the argument count anybody can read.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Prefs {
|
||||
pub autoplay: bool,
|
||||
pub appearance: Appearance,
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
shares: &[Share],
|
||||
form: &mut ShareForm,
|
||||
settings: &mut MetadataConfig,
|
||||
prefs: Prefs,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
@@ -47,240 +26,6 @@ pub fn show(
|
||||
|
||||
ui.add_space(22.0);
|
||||
add_form(ui, form, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
playback_form(ui, prefs.autoplay, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
appearance_form(ui, prefs.appearance, actions);
|
||||
|
||||
ui.add_space(28.0);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
});
|
||||
}
|
||||
|
||||
/// The theme: a flavour, an accent, and whether the accent is a gradient.
|
||||
///
|
||||
/// Every control here repaints the whole window the moment it is clicked —
|
||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||
/// only changed a small square would be asking someone to imagine the result.
|
||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Appearance");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted("Palette"));
|
||||
for flavor in Flavor::ALL {
|
||||
if ui
|
||||
.radio(appearance.flavor == flavor, flavor.label())
|
||||
.on_hover_text(flavor.description())
|
||||
.clicked()
|
||||
&& appearance.flavor != flavor
|
||||
{
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
flavor,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.label(ui::muted("Accent"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for accent in Accent::ALL {
|
||||
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
let mut gradients = appearance.gradients;
|
||||
if ui
|
||||
.checkbox(&mut gradients, "Fade the accent into a second colour")
|
||||
.on_hover_text(
|
||||
"Off draws it flat — better on a panel that bands a gradient into stripes",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetAppearance(Appearance {
|
||||
gradients,
|
||||
..appearance
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One accent, drawn in the colours it would actually produce.
|
||||
///
|
||||
/// Resolved against the *selected* flavour rather than the active palette, so
|
||||
/// the row previews what clicking it would give rather than what is on screen.
|
||||
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
||||
let selected = appearance.accent == accent;
|
||||
let size = egui::Vec2::new(52.0, 26.0);
|
||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
let palette = theme::Palette::resolve(Appearance {
|
||||
accent,
|
||||
..appearance
|
||||
});
|
||||
let radius = egui::CornerRadius::same(6);
|
||||
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
||||
if selected {
|
||||
// A ring rather than a tick: the tick would have to be drawn in an
|
||||
// ink that reads on eight different fills.
|
||||
ui.painter().rect_stroke(
|
||||
rect.expand(2.0),
|
||||
egui::CornerRadius::same(8),
|
||||
egui::Stroke::new(2.0, theme::text()),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
}
|
||||
response
|
||||
.on_hover_text(accent.label())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
||||
///
|
||||
/// The privacy note is not decoration and it is not in a tooltip. Turning this
|
||||
/// on sends the titles in someone's library to a third party, which is a thing
|
||||
/// they can only agree to if they are told — so it is stated where the switch
|
||||
/// is, before the switch, in the same size as everything else.
|
||||
/// The handful of playback settings that are not per-file.
|
||||
///
|
||||
/// Volume, audio language and subtitle language are all set from the player
|
||||
/// itself, where a viewer can hear the result — only autoplay has no natural
|
||||
/// home there, because by the time it matters the episode is over.
|
||||
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
|
||||
ui::section(ui, "Playback");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
let mut enabled = autoplay;
|
||||
if ui
|
||||
.checkbox(&mut enabled, "Play the next episode automatically")
|
||||
.on_hover_text("Only when an episode reaches its end, never after a failure")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetAutoplay(enabled));
|
||||
}
|
||||
ui.label(ui::muted(
|
||||
"Volume, audio language and subtitles are set from the player, and remembered \
|
||||
from there.",
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn metadata_form(
|
||||
ui: &mut egui::Ui,
|
||||
settings: &mut MetadataConfig,
|
||||
api_key: &mut String,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
ui::section(ui, "Posters and descriptions");
|
||||
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width().min(640.0));
|
||||
|
||||
ui.label(ui::muted(
|
||||
"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 \
|
||||
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.",
|
||||
));
|
||||
ui.add_space(10.0);
|
||||
|
||||
let mut changed = false;
|
||||
let mut enabled = settings.enabled;
|
||||
if ui
|
||||
.checkbox(&mut enabled, "Look up posters and descriptions")
|
||||
.changed()
|
||||
{
|
||||
settings.enabled = enabled;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if settings.enabled {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted("Provider"));
|
||||
for provider in ProviderId::ALL {
|
||||
if ui
|
||||
.radio(settings.provider == provider, provider.label())
|
||||
.on_hover_text(provider.description())
|
||||
.clicked()
|
||||
&& settings.provider != provider
|
||||
{
|
||||
settings.provider = provider;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if settings.provider.needs_api_key() {
|
||||
ui.add_space(10.0);
|
||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: settings.provider,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
));
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui::accent_button(ui, "Match the library").clicked() {
|
||||
actions.push(Action::MatchTitles { force: false });
|
||||
}
|
||||
if ui
|
||||
.button("Match everything again")
|
||||
.on_hover_text("Ask about every title, including ones already matched")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::MatchTitles { force: true });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ui.add_space(6.0);
|
||||
ui.label(ui::muted(
|
||||
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
||||
already stored.",
|
||||
));
|
||||
}
|
||||
|
||||
if changed {
|
||||
actions.push(Action::SetMetadataConfig(settings.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,6 +90,7 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
||||
ui.label(ui::muted("Link"));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut form.url)
|
||||
.password(true)
|
||||
.hint_text("https://drive.proton.me/urls/…#…")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
|
||||
+256
-31
@@ -5,15 +5,22 @@ use pstr_core::library::{Episode, Library, Season, Title, TitleKind};
|
||||
use pstr_core::metadata::TitleMetadata;
|
||||
|
||||
use crate::app::{Action, Page};
|
||||
use crate::engine::{DownloadItem, DownloadKey, DownloadState};
|
||||
use crate::playback::PlaybackTarget;
|
||||
use crate::theme;
|
||||
use crate::ui::{self, Art, Card};
|
||||
|
||||
pub struct OfflineView<'a> {
|
||||
pub downloads: &'a [DownloadItem],
|
||||
pub files: &'a std::collections::HashSet<DownloadKey>,
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
library: &Library,
|
||||
key: &str,
|
||||
offline: OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let Some(title) = library.get(key) else {
|
||||
@@ -31,25 +38,50 @@ pub fn show(
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
header(ui, art, title, actions);
|
||||
header(ui, art, title, &offline, actions);
|
||||
ui.add_space(18.0);
|
||||
|
||||
for (index, season) in title.seasons.iter().enumerate() {
|
||||
let open = index == 0 || title.seasons.len() == 1;
|
||||
egui::CollapsingHeader::new(
|
||||
egui::RichText::new(format!(
|
||||
"{} · {}",
|
||||
season.label(),
|
||||
ui::library::plural(season.episodes.len(), "episode")
|
||||
))
|
||||
.size(15.0)
|
||||
.strong(),
|
||||
theme::Role::Subhead
|
||||
.rich(format!(
|
||||
"{} · {}",
|
||||
season.label(),
|
||||
ui::library::plural(season.episodes.len(), "episode")
|
||||
))
|
||||
.strong(),
|
||||
)
|
||||
.id_salt(("season", index))
|
||||
.default_open(open)
|
||||
.show(ui, |ui| {
|
||||
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
|
||||
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
|
||||
if all_offline {
|
||||
if ui
|
||||
.button("Delete season")
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in season_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
}
|
||||
} else if ui
|
||||
.button("Download season")
|
||||
.on_hover_text("Download every episode in this season")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::MakeOffline(
|
||||
season
|
||||
.episodes
|
||||
.iter()
|
||||
.map(|episode| PlaybackTarget::new(title, episode))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
for episode in &season.episodes {
|
||||
episode_row(ui, art, title, season, episode, actions);
|
||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
@@ -58,28 +90,46 @@ pub fn show(
|
||||
}
|
||||
|
||||
/// The still, the name and the one button that matters.
|
||||
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
||||
fn header(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
title: &Title,
|
||||
offline: &OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
// Cloned out of the borrow: `art` is held mutably for the card below, and
|
||||
// the description is drawn beside it.
|
||||
let record = art.metadata.get(&title.key);
|
||||
let found = record.and_then(|record| record.metadata.clone());
|
||||
let by_hand = record.is_some_and(|record| record.manual);
|
||||
// Asked for once and used twice: the same picture is the poster beside the
|
||||
// text and the banner behind all of it.
|
||||
let picture = art.of(title);
|
||||
|
||||
// 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
|
||||
// on one title and leave a gap under a short one on the next.
|
||||
let backdrop = picture
|
||||
.as_ref()
|
||||
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui::card(
|
||||
ui,
|
||||
Card {
|
||||
art: art.of(title),
|
||||
art: picture,
|
||||
name: &title.name,
|
||||
subtitle: String::new(),
|
||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||
badge: None,
|
||||
width: theme::CARD_WIDTH,
|
||||
},
|
||||
);
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(&title.name).size(26.0).strong());
|
||||
ui.label(theme::Role::Display.rich(&title.name).strong());
|
||||
// The provider's name for it, when it is not the one the files use.
|
||||
// Worth showing rather than replacing the filename's: a viewer
|
||||
// should be able to tell what the match actually matched.
|
||||
@@ -110,6 +160,48 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
|
||||
if ui::accent_button(ui, &label).clicked() {
|
||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||
}
|
||||
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||
if all_offline {
|
||||
if ui
|
||||
.button("Delete show")
|
||||
.on_hover_text("Delete the offline copies; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
for key in title_keys {
|
||||
actions.push(Action::RemoveDownload(key, false));
|
||||
}
|
||||
}
|
||||
} else if ui
|
||||
.button("Download show")
|
||||
.on_hover_text("Download every episode for disconnected playback")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::MakeOffline(
|
||||
title
|
||||
.episodes()
|
||||
.map(|episode| PlaybackTarget::new(title, episode))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
let active = offline
|
||||
.downloads
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.target.title_key == title.key
|
||||
&& matches!(
|
||||
item.state,
|
||||
DownloadState::Queued
|
||||
| DownloadState::Running
|
||||
| DownloadState::Paused
|
||||
)
|
||||
})
|
||||
.count();
|
||||
if active > 0 {
|
||||
ui.label(ui::muted(format!("{active} downloading")));
|
||||
} else if all_offline {
|
||||
ui.label(ui::muted("Available offline"));
|
||||
}
|
||||
if resuming && let Some(at) = episode.resume_at() {
|
||||
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
|
||||
if ui
|
||||
@@ -148,6 +240,101 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some((texture, index)) = backdrop {
|
||||
ui.add_space(10.0);
|
||||
band(ui, &texture, index);
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture again, across the whole width, behind the header.
|
||||
///
|
||||
/// The one piece of this page that had to wait for artwork to exist at all: the
|
||||
/// providers already hand back a picture and its shape, so a backdrop costs no
|
||||
/// new request — only somewhere to put it.
|
||||
///
|
||||
/// Three shapes over one another, in this order: the picture, a flat veil of the
|
||||
/// page colour so text laid over it stays readable whatever the still happens to
|
||||
/// be, and a fade into the page along the bottom so the band ends rather than
|
||||
/// stops.
|
||||
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
|
||||
// Full width of the window, not of the content: the panel's own margin is
|
||||
// what the band has to reach past, or it reads as a picture in a box.
|
||||
const BLEED: f32 = 18.0;
|
||||
let content = ui.min_rect();
|
||||
let rect = egui::Rect::from_min_max(
|
||||
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
|
||||
egui::pos2(ui.max_rect().right() + BLEED, content.bottom()),
|
||||
);
|
||||
|
||||
// Past the panel margin, or the bleed is trimmed off at exactly the edge it
|
||||
// exists to cross.
|
||||
let painter = ui.painter().with_clip_rect(rect);
|
||||
// Every layer of the band goes into the one slot reserved before the header
|
||||
// was laid out. Painting the veils here, after the fact, would put them over
|
||||
// the name and the overview as well as the still — which is the picture
|
||||
// being readable at the cost of the text it exists behind.
|
||||
let mut layers: Vec<egui::Shape> = vec![
|
||||
egui::epaint::RectShape::filled(rect, egui::CornerRadius::ZERO, egui::Color32::WHITE)
|
||||
.with_texture(
|
||||
texture.id(),
|
||||
super::cover_uv(texture.size_vec2(), rect.size()),
|
||||
)
|
||||
.into(),
|
||||
];
|
||||
// Heavier than it looks: a still is arbitrary, and the one it lands on may
|
||||
// be a white frame. This is the difference between a hero and an unreadable
|
||||
// page.
|
||||
//
|
||||
// Two coats rather than one heavy one. The first is flat and near-opaque —
|
||||
// enough on its own that body text over the busiest frame still reads. The
|
||||
// second is the picture's own contrast being knocked back further behind the
|
||||
// left half, where the poster, the name and the overview actually sit; the
|
||||
// right half keeps more of the still, so the band is still a picture.
|
||||
//
|
||||
// Heavier still when there is no fade coming, since then these are the only
|
||||
// things standing between the text and the picture.
|
||||
let ramps = theme::ramps_on();
|
||||
layers.push(
|
||||
egui::epaint::RectShape::filled(
|
||||
rect,
|
||||
egui::CornerRadius::ZERO,
|
||||
theme::background().gamma_multiply(if ramps { 0.90 } else { 0.94 }),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
if ramps {
|
||||
let text_side = egui::Rect::from_min_max(
|
||||
rect.min,
|
||||
egui::pos2(rect.left() + rect.width() * 0.72, rect.bottom()),
|
||||
);
|
||||
layers.push(theme::fade_shape(
|
||||
ui.ctx(),
|
||||
text_side,
|
||||
theme::background(),
|
||||
theme::Direction::Horizontal,
|
||||
// Solid at the left, where the poster and the text are.
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
// A viewer with ramps off is on a panel that bands them; a 120-point ramp
|
||||
// of one hue is exactly the case that shows it. The band then ends at its
|
||||
// own edge, which is the honest version of the same thing.
|
||||
if ramps {
|
||||
let fade =
|
||||
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max);
|
||||
layers.push(theme::fade_shape(
|
||||
ui.ctx(),
|
||||
fade,
|
||||
theme::background(),
|
||||
theme::Direction::Vertical,
|
||||
// Solid at the bottom, where the page it hands over to is.
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
painter.set(index, egui::Shape::Vec(layers));
|
||||
}
|
||||
|
||||
/// What the provider had to say. Only ever drawn under a match.
|
||||
@@ -157,14 +344,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let Some(overview) = &found.overview {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(overview)
|
||||
.size(13.0)
|
||||
.color(theme::text()),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.add(egui::Label::new(theme::Role::Label.rich(overview).color(theme::text())).wrap());
|
||||
}
|
||||
if let Some(url) = &found.url {
|
||||
ui.add_space(8.0);
|
||||
@@ -225,6 +405,7 @@ fn episode_row(
|
||||
title: &Title,
|
||||
season: &Season,
|
||||
episode: &Episode,
|
||||
offline: &OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
// Cloned out before `ui` borrows: the row draws while `art` is held.
|
||||
@@ -251,15 +432,56 @@ fn episode_row(
|
||||
// carries a checkbox, and a click that means "seen" must never
|
||||
// be one that starts a 1.4 GiB stream instead.
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(egui::RichText::new("▶").size(13.0))
|
||||
.fill(theme::card_hover()),
|
||||
)
|
||||
.add(egui::Button::new(theme::Role::Label.rich("▶")).fill(theme::card_hover()))
|
||||
.on_hover_text("Play")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||
}
|
||||
let key = key_of(episode);
|
||||
let current = offline.downloads.iter().find(|item| item.key == key);
|
||||
if offline.files.contains(&key) {
|
||||
if ui
|
||||
.small_button("Delete")
|
||||
.on_hover_text("Delete the offline copy; keep the online source")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::RemoveDownload(key.clone(), false));
|
||||
}
|
||||
ui.label(ui::muted("✓ Offline"));
|
||||
} else if let Some(item) = current {
|
||||
match item.state {
|
||||
DownloadState::Running | DownloadState::Queued => {
|
||||
if ui.small_button("Pause").clicked() {
|
||||
actions.push(Action::PauseDownload(key.clone()));
|
||||
}
|
||||
ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0)));
|
||||
}
|
||||
DownloadState::Paused => {
|
||||
if ui.small_button("Resume").clicked() {
|
||||
actions.push(Action::ResumeDownload(key.clone()));
|
||||
}
|
||||
ui.label(ui::muted("Paused · partial kept"));
|
||||
}
|
||||
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
||||
if ui.small_button("Resume").clicked() {
|
||||
actions.push(Action::ResumeDownload(key.clone()));
|
||||
}
|
||||
ui.label(ui::muted("Partial"));
|
||||
}
|
||||
DownloadState::Completed => {
|
||||
ui.label(ui::muted("✓ Offline"));
|
||||
}
|
||||
}
|
||||
} else if ui
|
||||
.small_button("Download")
|
||||
.on_hover_text("Make available offline")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
|
||||
title, episode,
|
||||
)]));
|
||||
}
|
||||
|
||||
let numbering = episode
|
||||
.numbering()
|
||||
@@ -326,11 +548,7 @@ fn episode_row(
|
||||
// saying so on the row rather than only when a click on
|
||||
// it comes back with "has no content".
|
||||
Some(0) => {
|
||||
ui.label(
|
||||
egui::RichText::new("empty")
|
||||
.size(12.0)
|
||||
.color(theme::danger()),
|
||||
)
|
||||
ui.label(theme::Role::Caption.rich("empty").color(theme::danger()))
|
||||
.on_hover_text(
|
||||
"The share reports no content for this file; it cannot be played.",
|
||||
);
|
||||
@@ -342,8 +560,8 @@ fn episode_row(
|
||||
}
|
||||
if let Some(at) = episode.resume_at() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("resume {}", ui::format_time(at)))
|
||||
.size(12.0)
|
||||
theme::Role::Caption
|
||||
.rich(format!("resume {}", ui::format_time(at)))
|
||||
.color(theme::accent()),
|
||||
);
|
||||
}
|
||||
@@ -352,3 +570,10 @@ fn episode_row(
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
fn key_of(episode: &Episode) -> DownloadKey {
|
||||
DownloadKey {
|
||||
share_id: episode.node.share_id.clone(),
|
||||
link_id: episode.node.link_id.clone(),
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ pub fn mini(
|
||||
// one-way trip: playback carries on with nowhere to watch it.
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
|
||||
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
||||
.fill(theme::card_hover())
|
||||
.corner_radius(CornerRadius::same(8)),
|
||||
)
|
||||
@@ -132,12 +132,8 @@ pub fn mini(
|
||||
// is the only thing that still knows which title this came from.
|
||||
if ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&playback.target.title_name)
|
||||
.size(14.0)
|
||||
.strong(),
|
||||
)
|
||||
.sense(Sense::click()),
|
||||
egui::Label::new(theme::Role::Body.rich(&playback.target.title_name).strong())
|
||||
.sense(Sense::click()),
|
||||
)
|
||||
.on_hover_text("Show this title")
|
||||
.clicked()
|
||||
@@ -230,11 +226,17 @@ fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>,
|
||||
));
|
||||
} else {
|
||||
// Pause is symmetric, so it is centred the ordinary way.
|
||||
let (bar, height, gap) = (size * 0.09, size * 0.32, size * 0.10);
|
||||
for side in [-1.0f32, 1.0] {
|
||||
let x = centre.x + side * (gap / 2.0 + bar / 2.0);
|
||||
let bar = (size * 0.09).round();
|
||||
let height = (size * 0.32).round();
|
||||
let gap = (size * 0.10).round();
|
||||
let y = (centre.y - height / 2.0).round();
|
||||
|
||||
for x in [
|
||||
(centre.x - gap / 2.0 - bar).round(),
|
||||
(centre.x + gap / 2.0).round(),
|
||||
] {
|
||||
painter.rect_filled(
|
||||
Rect::from_center_size(pos2(x, centre.y), Vec2::new(bar, height)),
|
||||
Rect::from_min_size(pos2(x, y), Vec2::new(bar, height)),
|
||||
CornerRadius::same((bar / 2.0).round() as u8),
|
||||
theme::on_accent(),
|
||||
);
|
||||
@@ -281,7 +283,7 @@ fn step_button(
|
||||
) {
|
||||
let response = ui.add_enabled(
|
||||
enabled,
|
||||
egui::Button::new(egui::RichText::new(label).size(15.0)).min_size(Vec2::new(38.0, 30.0)),
|
||||
egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)),
|
||||
);
|
||||
if response.on_hover_text(hover).clicked() {
|
||||
push(actions);
|
||||
|
||||
@@ -19,7 +19,6 @@ thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
# The credential store, per platform. Without the target's feature `keyring`
|
||||
|
||||
@@ -151,6 +151,395 @@ pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
|
||||
write_json(&appearance_file(dirs), appearance)
|
||||
}
|
||||
|
||||
/// One colour, 8 bits a channel, with no alpha.
|
||||
///
|
||||
/// Deliberately not a front end's colour type: this module resolves a choice
|
||||
/// into colours for *both* clients, and neither egui's `Color32` nor Compose's
|
||||
/// `Color` may appear here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const BLACK: Self = Self::new(0, 0, 0);
|
||||
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
|
||||
/// Packed as `0xAARRGGBB`, opaque — the form both Android and a hex literal
|
||||
/// want.
|
||||
pub const fn argb(self) -> u32 {
|
||||
0xff00_0000 | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel from sRGB into linear light, as the sRGB transfer function
|
||||
/// defines it. Blending has to happen here: mixing sRGB values directly darkens
|
||||
/// the middle of a ramp between saturated hues, which is exactly where a
|
||||
/// gradient is looked at.
|
||||
fn linear(channel: u8) -> f32 {
|
||||
let value = channel as f32 / 255.0;
|
||||
if value <= 0.04045 {
|
||||
value / 12.92
|
||||
} else {
|
||||
((value + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Back out of linear light, rounded to a channel.
|
||||
fn gamma(value: f32) -> u8 {
|
||||
let value = value.clamp(0.0, 1.0);
|
||||
let encoded = if value <= 0.003_130_8 {
|
||||
value * 12.92
|
||||
} else {
|
||||
1.055 * value.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// Blend two colours in linear light.
|
||||
pub fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb {
|
||||
let blend = |from: u8, to: u8| gamma(linear(from) * (1.0 - t) + linear(to) * t);
|
||||
Rgb::new(
|
||||
blend(from.r, to.r),
|
||||
blend(from.g, to.g),
|
||||
blend(from.b, to.b),
|
||||
)
|
||||
}
|
||||
|
||||
/// Relative luminance, in linear light, as WCAG defines it.
|
||||
pub fn luminance(color: Rgb) -> f32 {
|
||||
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
|
||||
}
|
||||
|
||||
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
||||
/// 21.0 for black against white.
|
||||
fn contrast(one: Rgb, other: Rgb) -> f32 {
|
||||
let (one, other) = (luminance(one), luminance(other));
|
||||
(one.max(other) + 0.05) / (one.min(other) + 0.05)
|
||||
}
|
||||
|
||||
/// The least contrast a label gets anywhere along a gradient between two
|
||||
/// colours. Sampled rather than solved: the worst point is not always an end,
|
||||
/// because the ink can be brighter than one end and darker than the other.
|
||||
pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
|
||||
(0..=4)
|
||||
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
}
|
||||
|
||||
/// The least contrast the app will accept between a label and the fill under
|
||||
/// it: WCAG AA for large text, which is what wears the accent — button labels,
|
||||
/// a tab, a play glyph, never body copy.
|
||||
pub const MIN_CONTRAST: f32 = 3.0;
|
||||
|
||||
/// Every colour a client draws with, resolved from a [`Flavor`] and an
|
||||
/// [`Accent`].
|
||||
///
|
||||
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
|
||||
/// a drawing loop has to think about.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
/// Behind everything: the page.
|
||||
pub background: Rgb,
|
||||
/// The bars above and below it.
|
||||
pub surface: Rgb,
|
||||
/// The deepest colour in the flavour — text fields, wells.
|
||||
pub sunken: Rgb,
|
||||
/// Tiles, rows, forms.
|
||||
pub card: Rgb,
|
||||
pub card_hover: Rgb,
|
||||
pub border: Rgb,
|
||||
pub text: Rgb,
|
||||
pub muted: Rgb,
|
||||
/// The one strong colour.
|
||||
pub accent: Rgb,
|
||||
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
|
||||
/// the accent is a single hue and gradients would have nothing to do.
|
||||
pub accent_alt: Rgb,
|
||||
/// The accent taken down into the background, for a pressed control and for
|
||||
/// selection behind text.
|
||||
pub accent_dim: Rgb,
|
||||
/// Ink that stays readable on top of the accent. Chosen by the accent's
|
||||
/// luminance, because Catppuccin's dark flavours have *pale* accents and
|
||||
/// white-on-pastel is not readable at 13 px.
|
||||
pub on_accent: Rgb,
|
||||
pub danger: Rgb,
|
||||
/// Whether this is a light theme.
|
||||
pub light: bool,
|
||||
}
|
||||
|
||||
/// One flavour's raw colours, named as Catppuccin names them.
|
||||
///
|
||||
/// Stored rather than computed: these are somebody else's palette, and the
|
||||
/// point of using it is to use it exactly.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Ramp {
|
||||
base: Rgb,
|
||||
mantle: Rgb,
|
||||
crust: Rgb,
|
||||
surface0: Rgb,
|
||||
surface1: Rgb,
|
||||
subtext0: Rgb,
|
||||
text: Rgb,
|
||||
pink: Rgb,
|
||||
mauve: Rgb,
|
||||
sky: Rgb,
|
||||
sapphire: Rgb,
|
||||
blue: Rgb,
|
||||
lavender: Rgb,
|
||||
teal: Rgb,
|
||||
green: Rgb,
|
||||
peach: Rgb,
|
||||
yellow: Rgb,
|
||||
red: Rgb,
|
||||
light: bool,
|
||||
}
|
||||
|
||||
const fn rgb(hex: u32) -> Rgb {
|
||||
Rgb::new(
|
||||
((hex >> 16) & 0xff) as u8,
|
||||
((hex >> 8) & 0xff) as u8,
|
||||
(hex & 0xff) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// The palette this app shipped with, extended to the hues an accent can be.
|
||||
///
|
||||
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
|
||||
/// chosen to sit on a near-black base at roughly the saturation that one does,
|
||||
/// which is a good deal hotter than any Catppuccin flavour.
|
||||
const PROTON: Ramp = Ramp {
|
||||
base: rgb(0x0e0e12),
|
||||
mantle: rgb(0x17171d),
|
||||
crust: rgb(0x0a0a0d),
|
||||
surface0: rgb(0x1e1e26),
|
||||
surface1: rgb(0x2a2a35),
|
||||
subtext0: rgb(0x8e8e9c),
|
||||
text: rgb(0xeaeaf0),
|
||||
pink: rgb(0xff5fbe),
|
||||
mauve: rgb(0x7d4dff),
|
||||
sky: rgb(0x4dd2ff),
|
||||
sapphire: rgb(0x3a8fc4),
|
||||
blue: rgb(0x3f6fe0),
|
||||
lavender: rgb(0xa68cff),
|
||||
teal: rgb(0x3fd6b8),
|
||||
green: rgb(0x56d364),
|
||||
peach: rgb(0xff9a4d),
|
||||
yellow: rgb(0xffd166),
|
||||
red: rgb(0xe05561),
|
||||
light: false,
|
||||
};
|
||||
|
||||
const LATTE: Ramp = Ramp {
|
||||
base: rgb(0xeff1f5),
|
||||
mantle: rgb(0xe6e9ef),
|
||||
crust: rgb(0xdce0e8),
|
||||
surface0: rgb(0xccd0da),
|
||||
surface1: rgb(0xbcc0cc),
|
||||
subtext0: rgb(0x6c6f85),
|
||||
text: rgb(0x4c4f69),
|
||||
pink: rgb(0xea76cb),
|
||||
mauve: rgb(0x8839ef),
|
||||
sky: rgb(0x04a5e5),
|
||||
sapphire: rgb(0x209fb5),
|
||||
blue: rgb(0x1e66f5),
|
||||
lavender: rgb(0x7287fd),
|
||||
teal: rgb(0x179299),
|
||||
green: rgb(0x40a02b),
|
||||
peach: rgb(0xfe640b),
|
||||
yellow: rgb(0xdf8e1d),
|
||||
red: rgb(0xd20f39),
|
||||
light: true,
|
||||
};
|
||||
|
||||
const FRAPPE: Ramp = Ramp {
|
||||
base: rgb(0x303446),
|
||||
mantle: rgb(0x292c3c),
|
||||
crust: rgb(0x232634),
|
||||
surface0: rgb(0x414559),
|
||||
surface1: rgb(0x51576d),
|
||||
subtext0: rgb(0xa5adce),
|
||||
text: rgb(0xc6d0f5),
|
||||
pink: rgb(0xf4b8e4),
|
||||
mauve: rgb(0xca9ee6),
|
||||
sky: rgb(0x99d1db),
|
||||
sapphire: rgb(0x85c1dc),
|
||||
blue: rgb(0x8caaee),
|
||||
lavender: rgb(0xbabbf1),
|
||||
teal: rgb(0x81c8be),
|
||||
green: rgb(0xa6d189),
|
||||
peach: rgb(0xef9f76),
|
||||
yellow: rgb(0xe5c890),
|
||||
red: rgb(0xe78284),
|
||||
light: false,
|
||||
};
|
||||
|
||||
const MACCHIATO: Ramp = Ramp {
|
||||
base: rgb(0x24273a),
|
||||
mantle: rgb(0x1e2030),
|
||||
crust: rgb(0x181926),
|
||||
surface0: rgb(0x363a4f),
|
||||
surface1: rgb(0x494d64),
|
||||
subtext0: rgb(0xa5adcb),
|
||||
text: rgb(0xcad3f5),
|
||||
pink: rgb(0xf5bde6),
|
||||
mauve: rgb(0xc6a0f6),
|
||||
sky: rgb(0x91d7e3),
|
||||
sapphire: rgb(0x7dc4e4),
|
||||
blue: rgb(0x8aadf4),
|
||||
lavender: rgb(0xb7bdf8),
|
||||
teal: rgb(0x8bd5ca),
|
||||
green: rgb(0xa6da95),
|
||||
peach: rgb(0xf5a97f),
|
||||
yellow: rgb(0xeed49f),
|
||||
red: rgb(0xed8796),
|
||||
light: false,
|
||||
};
|
||||
|
||||
const MOCHA: Ramp = Ramp {
|
||||
base: rgb(0x1e1e2e),
|
||||
mantle: rgb(0x181825),
|
||||
crust: rgb(0x11111b),
|
||||
surface0: rgb(0x313244),
|
||||
surface1: rgb(0x45475a),
|
||||
subtext0: rgb(0xa6adc8),
|
||||
text: rgb(0xcdd6f4),
|
||||
pink: rgb(0xf5c2e7),
|
||||
mauve: rgb(0xcba6f7),
|
||||
sky: rgb(0x89dceb),
|
||||
sapphire: rgb(0x74c7ec),
|
||||
blue: rgb(0x89b4fa),
|
||||
lavender: rgb(0xb4befe),
|
||||
teal: rgb(0x94e2d5),
|
||||
green: rgb(0xa6e3a1),
|
||||
peach: rgb(0xfab387),
|
||||
yellow: rgb(0xf9e2af),
|
||||
red: rgb(0xf38ba8),
|
||||
light: false,
|
||||
};
|
||||
|
||||
impl Ramp {
|
||||
const fn of(flavor: Flavor) -> Self {
|
||||
match flavor {
|
||||
Flavor::Proton => PROTON,
|
||||
Flavor::Latte => LATTE,
|
||||
Flavor::Frappe => FRAPPE,
|
||||
Flavor::Macchiato => MACCHIATO,
|
||||
Flavor::Mocha => MOCHA,
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent, and the hue a gradient in it runs into.
|
||||
///
|
||||
/// The partners are neighbours on the wheel rather than complements: a
|
||||
/// gradient across half the spectrum passes through a colour that belongs
|
||||
/// to neither end, and on a seek bar that reads as a bug. The exception is
|
||||
/// [`Accent::PinkSky`], which is the whole point of that entry.
|
||||
const fn accents(&self, accent: Accent) -> (Rgb, Rgb) {
|
||||
match accent {
|
||||
Accent::Mauve => (self.mauve, self.blue),
|
||||
Accent::Pink => (self.pink, self.mauve),
|
||||
Accent::Sky => (self.sky, self.sapphire),
|
||||
Accent::PinkSky => (self.pink, self.sky),
|
||||
Accent::Lavender => (self.lavender, self.blue),
|
||||
Accent::Blue => (self.blue, self.sapphire),
|
||||
Accent::Teal => (self.teal, self.green),
|
||||
Accent::Peach => (self.peach, self.yellow),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The default, spelled out as a constant so the global has something to
|
||||
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
|
||||
/// panic message painted before the engine exists, still gets colours.
|
||||
pub const PROTON: Self = Self {
|
||||
background: PROTON.base,
|
||||
surface: PROTON.mantle,
|
||||
sunken: PROTON.crust,
|
||||
card: PROTON.surface0,
|
||||
card_hover: PROTON.surface1,
|
||||
border: rgb(0x262630),
|
||||
text: PROTON.text,
|
||||
muted: PROTON.subtext0,
|
||||
accent: PROTON.mauve,
|
||||
accent_alt: PROTON.blue,
|
||||
accent_dim: rgb(0x5333ad),
|
||||
on_accent: PROTON.text,
|
||||
danger: PROTON.red,
|
||||
light: false,
|
||||
};
|
||||
|
||||
/// Resolve a choice into colours.
|
||||
pub fn resolve(appearance: Appearance) -> Self {
|
||||
let ramp = Ramp::of(appearance.flavor);
|
||||
let (accent, partner) = ramp.accents(appearance.accent);
|
||||
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
|
||||
// at body-text weight — which makes them mid-luminance, and a
|
||||
// mid-luminance fill is one that neither black nor white sits on. They
|
||||
// are taken down in value here, and only here: the hue and the
|
||||
// saturation are Latte's, so it still looks like Latte, and a label on
|
||||
// a button is legible.
|
||||
let deepen = |color: Rgb| {
|
||||
if ramp.light {
|
||||
mix(color, Rgb::BLACK, 0.30)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
};
|
||||
let accent = deepen(accent);
|
||||
let accent_alt = if appearance.gradients {
|
||||
deepen(partner)
|
||||
} else {
|
||||
accent
|
||||
};
|
||||
|
||||
// Ink that survives on the accent. The flavour's own reading first —
|
||||
// pale text on a dark theme — and the opposite when that does not clear
|
||||
// the bar, which on Catppuccin's dark flavours is most of the time:
|
||||
// their accents are *pastel*, and white on #f5c2e7 is not a label.
|
||||
//
|
||||
// Latte's darkest colour is its body text, and even that is only #4c4f69,
|
||||
// so the dark side is taken a third of the way to black.
|
||||
let ink_dark = if ramp.light {
|
||||
mix(ramp.text, Rgb::BLACK, 0.35)
|
||||
} else {
|
||||
ramp.crust
|
||||
};
|
||||
let ink_light = if ramp.light { ramp.base } else { ramp.text };
|
||||
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
|
||||
ink_light
|
||||
} else {
|
||||
ink_dark
|
||||
};
|
||||
|
||||
Self {
|
||||
background: ramp.base,
|
||||
surface: ramp.mantle,
|
||||
sunken: ramp.crust,
|
||||
card: ramp.surface0,
|
||||
card_hover: ramp.surface1,
|
||||
border: ramp.surface1,
|
||||
text: ramp.text,
|
||||
muted: ramp.subtext0,
|
||||
accent,
|
||||
accent_alt,
|
||||
// Behind text, so it is the accent taken most of the way back to
|
||||
// the page: a selection in a saturated hue is a selection nobody
|
||||
// can read through.
|
||||
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
|
||||
on_accent,
|
||||
danger: ramp.red,
|
||||
light: ramp.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -209,4 +598,54 @@ mod tests {
|
||||
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_flavour_and_accent_resolves_to_readable_ink() {
|
||||
for flavor in Flavor::ALL {
|
||||
for accent in Accent::ALL {
|
||||
let palette = Palette::resolve(Appearance {
|
||||
flavor,
|
||||
accent,
|
||||
gradients: true,
|
||||
});
|
||||
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
|
||||
assert!(
|
||||
ratio >= MIN_CONTRAST,
|
||||
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_light_flavour_reports_itself_light() {
|
||||
for flavor in Flavor::ALL {
|
||||
let palette = Palette::resolve(Appearance {
|
||||
flavor,
|
||||
..Appearance::default()
|
||||
});
|
||||
assert_eq!(palette.light, flavor.is_light());
|
||||
// And the page is on the right side of the middle either way.
|
||||
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_gradients_off_leaves_one_colour_to_draw() {
|
||||
let flat = Palette::resolve(Appearance {
|
||||
accent: Accent::PinkSky,
|
||||
gradients: false,
|
||||
..Appearance::default()
|
||||
});
|
||||
assert_eq!(flat.accent, flat.accent_alt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blend_stays_between_the_two_colours_it_blends() {
|
||||
let (from, to) = (Rgb::new(0, 0, 0), Rgb::new(255, 255, 255));
|
||||
assert_eq!(mix(from, to, 0.0), from);
|
||||
assert_eq!(mix(from, to, 1.0), to);
|
||||
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
|
||||
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
||||
use crate::naming::{self, ParsedName};
|
||||
|
||||
/// Bump when adding a migration.
|
||||
pub const SCHEMA_VERSION: i64 = 5;
|
||||
pub const SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
CREATE TABLE nodes (
|
||||
@@ -128,6 +128,46 @@ const MIGRATION_V5: &str = r#"
|
||||
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
|
||||
"#;
|
||||
|
||||
/// Content kept explicitly by the viewer. Unlike the block cache, these rows
|
||||
/// describe complete local files and therefore survive a disconnected launch.
|
||||
const MIGRATION_V6: &str = r#"
|
||||
CREATE TABLE offline_files (
|
||||
share_id TEXT NOT NULL, link_id TEXT NOT NULL, revision_id TEXT NOT NULL,
|
||||
block_sizes TEXT NOT NULL,
|
||||
PRIMARY KEY (share_id, link_id)
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Language choices are a property of a show, not of an episode's muxing.
|
||||
const MIGRATION_V7: &str = r#"
|
||||
CREATE TABLE title_track_prefs (
|
||||
title_key TEXT PRIMARY KEY, audio_language TEXT, subtitle_language TEXT,
|
||||
subtitles INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OfflineFile {
|
||||
pub revision_id: String,
|
||||
pub block_sizes: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TitleTrackPrefs {
|
||||
pub audio_language: Option<String>,
|
||||
pub subtitle_language: Option<String>,
|
||||
pub subtitles: bool,
|
||||
}
|
||||
impl Default for TitleTrackPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
audio_language: None,
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||
const ABSOLUTE_SEASON: i64 = -1;
|
||||
|
||||
@@ -187,6 +227,16 @@ impl Catalog {
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
/// How many rows this connection has written since it was opened.
|
||||
///
|
||||
/// A cache key, not a statistic: anything derived from the catalog stays
|
||||
/// valid exactly as long as this does not move. SQLite counts it for us, so
|
||||
/// no write path has to remember to invalidate anything — which is the
|
||||
/// failure mode a hand-maintained counter has.
|
||||
pub fn writes(&self) -> u64 {
|
||||
self.conn.total_changes()
|
||||
}
|
||||
|
||||
fn migrate(&mut self) -> Result<()> {
|
||||
let version: i64 = self
|
||||
.conn
|
||||
@@ -235,6 +285,18 @@ impl Catalog {
|
||||
tx.pragma_update(None, "user_version", 5)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 6 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V6)?;
|
||||
tx.pragma_update(None, "user_version", 6)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
if version < 7 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V7)?;
|
||||
tx.pragma_update(None, "user_version", 7)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -245,6 +307,25 @@ impl Catalog {
|
||||
/// intact rather than a half-replaced one. Watch state is keyed separately
|
||||
/// and deliberately untouched — a recrawl must not lose where you were.
|
||||
pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> {
|
||||
self.replace_share_inner(share_id, nodes, false)
|
||||
}
|
||||
|
||||
/// Replace nodes while retaining stale offline path keys for explicit
|
||||
/// filesystem cleanup by a front end.
|
||||
pub fn replace_share_retaining_offline(
|
||||
&mut self,
|
||||
share_id: &str,
|
||||
nodes: &[CatalogNode],
|
||||
) -> Result<()> {
|
||||
self.replace_share_inner(share_id, nodes, true)
|
||||
}
|
||||
|
||||
fn replace_share_inner(
|
||||
&mut self,
|
||||
share_id: &str,
|
||||
nodes: &[CatalogNode],
|
||||
retain_offline: bool,
|
||||
) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
|
||||
@@ -276,6 +357,10 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
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.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,16 +399,141 @@ impl Catalog {
|
||||
/// state alone — this is the viewer saying they are done with the share.
|
||||
/// Keeping positions for files they can no longer reach would only leave
|
||||
/// the "continue watching" row pointing at nothing.
|
||||
pub fn remove_share(&self, share_id: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
self.conn.execute(
|
||||
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM watch_state WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn offline_file(&self, share_id: &str, link_id: &str) -> Result<Option<OfflineFile>> {
|
||||
self.conn.query_row("SELECT revision_id, block_sizes FROM offline_files WHERE share_id=?1 AND link_id=?2", params![share_id, link_id], |row| {
|
||||
let sizes: String = row.get(1)?;
|
||||
Ok(OfflineFile { revision_id: row.get(0)?, block_sizes: serde_json::from_str(&sizes).map_err(|e| rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)))? })
|
||||
}).optional().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_offline_file(
|
||||
&self,
|
||||
share_id: &str,
|
||||
link_id: &str,
|
||||
file: &OfflineFile,
|
||||
) -> Result<()> {
|
||||
let sizes = serde_json::to_string(&file.block_sizes)
|
||||
.map_err(|e| crate::error::Error::Config(e.to_string()))?;
|
||||
self.conn.execute("INSERT INTO offline_files (share_id,link_id,revision_id,block_sizes) VALUES (?1,?2,?3,?4) ON CONFLICT(share_id,link_id) DO UPDATE SET revision_id=excluded.revision_id,block_sizes=excluded.block_sizes", params![share_id,link_id,file.revision_id,sizes])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a completed download and force its WAL transaction to stable
|
||||
/// storage before the caller discards its recovery journal.
|
||||
pub fn set_offline_file_durable(
|
||||
&self,
|
||||
share_id: &str,
|
||||
link_id: &str,
|
||||
file: &OfflineFile,
|
||||
) -> Result<()> {
|
||||
self.set_offline_file(share_id, link_id, file)?;
|
||||
self.conn
|
||||
.query_row("PRAGMA wal_checkpoint(FULL)", [], |_| Ok(()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every completed local revision, keyed like watch state.
|
||||
pub fn all_offline_files(&self) -> Result<HashMap<(String, String), OfflineFile>> {
|
||||
let mut statement = self
|
||||
.conn
|
||||
.prepare("SELECT share_id, link_id, revision_id, block_sizes FROM offline_files")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
let sizes: String = row.get(3)?;
|
||||
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
3,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(error),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
(row.get(0)?, row.get(1)?),
|
||||
OfflineFile {
|
||||
revision_id: row.get(2)?,
|
||||
block_sizes,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
/// Completed local revisions belonging to one share.
|
||||
///
|
||||
/// Callers that replace or remove a share need the paths before deleting
|
||||
/// the index rows. Keeping this query explicit prevents an SQL cascade
|
||||
/// from orphaning opaque media files on disk.
|
||||
pub fn offline_files_for_share(&self, share_id: &str) -> Result<HashMap<String, OfflineFile>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT link_id, revision_id, block_sizes FROM offline_files WHERE share_id=?1",
|
||||
)?;
|
||||
let rows = statement.query_map(params![share_id], |row| {
|
||||
let sizes: String = row.get(2)?;
|
||||
let block_sizes = serde_json::from_str(&sizes).map_err(|error| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(error),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
OfflineFile {
|
||||
revision_id: row.get(1)?,
|
||||
block_sizes,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn remove_offline_file(&self, share_id: &str, link_id: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM offline_files WHERE share_id=?1 AND link_id=?2",
|
||||
params![share_id, link_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
||||
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
|
||||
}
|
||||
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
||||
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
|
||||
)?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
TitleTrackPrefs {
|
||||
audio_language: row.get(1)?,
|
||||
subtitle_language: row.get(2)?,
|
||||
subtitles: row.get::<_, i64>(3)? != 0,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||
}
|
||||
|
||||
/// Every watch position there is, keyed by `(share_id, link_id)`.
|
||||
///
|
||||
/// What [`crate::library::Library::build`] joins against. A grid that asked
|
||||
@@ -783,7 +993,7 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
||||
// episode's name rather than the series'.
|
||||
if row.parsed.episode_title.is_none()
|
||||
&& !row.parsed.title.is_empty()
|
||||
&& row.parsed.title != title
|
||||
&& !row.parsed.title.eq_ignore_ascii_case(&title)
|
||||
{
|
||||
let rest = strip_prefix_title(&row.parsed.title, &title);
|
||||
// …unless what is left is a season marker. `Oshi no Ko 3rd
|
||||
@@ -792,7 +1002,11 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
|
||||
// that is worse than calling none of them anything.
|
||||
if naming::is_season_marker(&rest) {
|
||||
row.parsed.season = row.parsed.season.or(naming::parse(&rest).season);
|
||||
} else {
|
||||
} else if !rest.eq_ignore_ascii_case(&title) {
|
||||
// Nor when the strip found nothing to remove: a filename
|
||||
// whose title only *differs* from the folder's — spacing,
|
||||
// punctuation, a stray article — names the series, not the
|
||||
// episode, and taking it hands every row the same text.
|
||||
row.parsed.episode_title = Some(rest);
|
||||
}
|
||||
}
|
||||
@@ -876,10 +1090,17 @@ fn ancestry(
|
||||
///
|
||||
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
|
||||
/// `Neon Genesis Evangelion` should read as `Death & Rebirth`.
|
||||
///
|
||||
/// Case-insensitively, because the two names come from different places: the
|
||||
/// folder is typed by whoever organised the share and the prefix is whatever a
|
||||
/// release group capitalised. `[Anime Time] Attack On Titan - 38.mkv` under a
|
||||
/// folder called `Attack on Titan` differs in exactly one letter, and a
|
||||
/// case-sensitive strip leaves the whole series name behind as the episode's.
|
||||
fn strip_prefix_title(episode: &str, series: &str) -> String {
|
||||
let stripped = episode
|
||||
.strip_prefix(series)
|
||||
.map(|rest| rest.trim_start_matches([' ', '-', '_', '.', ':']))
|
||||
.get(..series.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(series))
|
||||
.map(|_| episode[series.len()..].trim_start_matches([' ', '-', '_', '.', ':']))
|
||||
.unwrap_or(episode)
|
||||
.trim();
|
||||
|
||||
@@ -1432,6 +1653,47 @@ mod tests {
|
||||
assert_eq!(episode.parsed.episode_title, None);
|
||||
}
|
||||
|
||||
/// **What every episode of Attack on Titan was called.** The folder and the
|
||||
/// release group capitalised the same series two ways, and a case-sensitive
|
||||
/// prefix strip left the whole series name behind — so all fifty-nine rows
|
||||
/// read `Attack On Titan`, naming nothing and telling the viewer apart from
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn a_series_name_cased_differently_is_not_the_episodes_name() {
|
||||
let nodes = vec![
|
||||
root(),
|
||||
folder("aot", Some("root"), "Attack on Titan"),
|
||||
video("e38", "aot", "[Anime Time] Attack On Titan - 38.mkv"),
|
||||
];
|
||||
let rows = build_rows("share", &nodes);
|
||||
let episode = row(&rows, "e38");
|
||||
|
||||
assert_eq!(episode.parsed.title, "Attack on Titan", "the folder's");
|
||||
assert_eq!(episode.parsed.episode, Some(38));
|
||||
assert_eq!(episode.parsed.episode_title, None);
|
||||
}
|
||||
|
||||
/// The prefix strip is case-insensitive for the same reason: what follows
|
||||
/// the series name is the episode's, however the group capitalised it.
|
||||
#[test]
|
||||
fn a_prefix_cased_differently_is_still_stripped() {
|
||||
let nodes = vec![
|
||||
root(),
|
||||
folder("eva", Some("root"), "Neon Genesis Evangelion"),
|
||||
video(
|
||||
"film",
|
||||
"eva",
|
||||
"NEON GENESIS EVANGELION - Death & Rebirth.mkv",
|
||||
),
|
||||
];
|
||||
let rows = build_rows("share", &nodes);
|
||||
|
||||
assert_eq!(
|
||||
row(&rows, "film").parsed.episode_title.as_deref(),
|
||||
Some("Death & Rebirth"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The deepest naming folder wins, so films inside a collection folder are
|
||||
/// titled individually rather than by the collection.
|
||||
#[test]
|
||||
@@ -1497,4 +1759,94 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_offline_record_keeps_the_online_catalog_and_other_downloads() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
let first = OfflineFile {
|
||||
revision_id: "revision-1".to_owned(),
|
||||
block_sizes: vec![3, 5],
|
||||
};
|
||||
let second = OfflineFile {
|
||||
revision_id: "revision-2".to_owned(),
|
||||
block_sizes: vec![7],
|
||||
};
|
||||
catalog
|
||||
.set_offline_file("share", "first", &first)
|
||||
.expect("record first");
|
||||
catalog
|
||||
.set_offline_file("share", "second", &second)
|
||||
.expect("record second");
|
||||
|
||||
catalog
|
||||
.remove_offline_file("share", "first")
|
||||
.expect("remove first");
|
||||
|
||||
assert_eq!(
|
||||
catalog.offline_file("share", "first").expect("read first"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.offline_file("share", "second")
|
||||
.expect("read second"),
|
||||
Some(second)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recrawl_retains_stale_offline_index_until_bytes_can_be_removed() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let mut old = file("share", "episode", "Show.S01E01.mkv");
|
||||
old.active_revision_id = Some("old".to_owned());
|
||||
catalog.replace_share("share", &[old]).expect("first crawl");
|
||||
let offline = OfflineFile {
|
||||
revision_id: "old".to_owned(),
|
||||
block_sizes: vec![4, 7],
|
||||
};
|
||||
catalog
|
||||
.set_offline_file("share", "episode", &offline)
|
||||
.expect("record offline revision");
|
||||
|
||||
let mut new = file("share", "episode", "Show.S01E01.mkv");
|
||||
new.active_revision_id = Some("new".to_owned());
|
||||
catalog
|
||||
.replace_share_retaining_offline("share", &[new])
|
||||
.expect("recrawl");
|
||||
|
||||
assert_eq!(
|
||||
catalog
|
||||
.offline_file("share", "episode")
|
||||
.expect("read retained path key"),
|
||||
Some(offline),
|
||||
"filesystem owner must delete old bytes before dropping this path key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_recrawl_prunes_stale_offline_index_for_legacy_callers() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let mut old = file("share", "episode", "Show.S01E01.mkv");
|
||||
old.active_revision_id = Some("old".to_owned());
|
||||
catalog.replace_share("share", &[old]).expect("first crawl");
|
||||
catalog
|
||||
.set_offline_file(
|
||||
"share",
|
||||
"episode",
|
||||
&OfflineFile {
|
||||
revision_id: "old".to_owned(),
|
||||
block_sizes: vec![4],
|
||||
},
|
||||
)
|
||||
.expect("offline row");
|
||||
let mut new = file("share", "episode", "Show.S01E01.mkv");
|
||||
new.active_revision_id = Some("new".to_owned());
|
||||
catalog.replace_share("share", &[new]).expect("recrawl");
|
||||
assert_eq!(
|
||||
catalog
|
||||
.offline_file("share", "episode")
|
||||
.expect("read offline row"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! The chapters a file was muxed with, and what they are for.
|
||||
//!
|
||||
//! Anime releases mark their opening and ending as chapters, which is the whole
|
||||
//! reason this exists: with them a player can offer to skip the ninety seconds
|
||||
//! everyone skips, and without them it cannot.
|
||||
//!
|
||||
//! Nothing here talks to a player. Reading the chapter list out of mpv is
|
||||
//! `pstr-player`'s job on the desktop and the JNI adapter's on Android; both
|
||||
//! hand the result to [`roles`], so a chapter means the same thing on either.
|
||||
|
||||
/// One chapter of the file.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Chapter {
|
||||
/// Position in the list, which is what mpv's `chapter` property is set to.
|
||||
pub index: i64,
|
||||
/// The name the muxer gave it, if any.
|
||||
pub title: Option<String>,
|
||||
/// Where it starts, in seconds.
|
||||
pub start: f64,
|
||||
}
|
||||
|
||||
impl Chapter {
|
||||
/// What to show in a list. A nameless chapter is still worth an entry —
|
||||
/// it is a place in the file — so it gets its number.
|
||||
pub fn label(&self) -> String {
|
||||
match &self.title {
|
||||
Some(title) => title.clone(),
|
||||
None => format!("Chapter {}", self.index + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// What this chapter is, from its name alone.
|
||||
///
|
||||
/// Prefer [`roles`], which resolves the names that only mean one thing *in
|
||||
/// context* — a chapter called `Intro` is an opening in one release and the
|
||||
/// first ten minutes of the story in another.
|
||||
pub fn role(&self) -> ChapterRole {
|
||||
match Claim::of(self.title.as_deref().unwrap_or_default()) {
|
||||
Claim::Opening => ChapterRole::Opening,
|
||||
Claim::Ending => ChapterRole::Ending,
|
||||
Claim::Preview => ChapterRole::Preview,
|
||||
// Unresolved without the rest of the file: assume it is content,
|
||||
// which is the reading that never skips anything.
|
||||
_ => ChapterRole::Content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of chapter worth treating differently from "part of the episode".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChapterRole {
|
||||
/// The opening titles.
|
||||
Opening,
|
||||
/// The ending titles.
|
||||
Ending,
|
||||
/// A preview of the next episode, after the ending.
|
||||
Preview,
|
||||
/// Anything else, including everything unnamed.
|
||||
Content,
|
||||
}
|
||||
|
||||
impl ChapterRole {
|
||||
/// What a button offering to skip this should say. `None` for a chapter
|
||||
/// nobody wants skipped.
|
||||
pub fn skip_label(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Opening => Some("Skip opening"),
|
||||
Self::Ending => Some("Skip ending"),
|
||||
Self::Preview => Some("Skip preview"),
|
||||
Self::Content => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is something the file could end on without the viewer
|
||||
/// missing anything: credits and a trailer for next week.
|
||||
///
|
||||
/// An *opening* is not, even though it is skippable — a file does not end
|
||||
/// with one, and treating it as tail would make a mis-tagged chapter
|
||||
/// auto-advance out of the episode.
|
||||
pub fn ends_the_episode(self) -> bool {
|
||||
matches!(self, Self::Ending | Self::Preview)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chapter's *name* claims, before the rest of the file is consulted.
|
||||
///
|
||||
/// The two `Maybe` variants are the whole reason this is separate from
|
||||
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
|
||||
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
|
||||
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
|
||||
/// files and a mid-episode scene in a few. Neither can be settled by the name.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Claim {
|
||||
Opening,
|
||||
Ending,
|
||||
Preview,
|
||||
/// Reads like an opening, but only if the file has no real one and it is
|
||||
/// the length of a theme song.
|
||||
MaybeOpening,
|
||||
/// Reads like credits, but only near the end of the file or at theme
|
||||
/// length.
|
||||
MaybeEnding,
|
||||
Content,
|
||||
}
|
||||
|
||||
/// How long an opening or ending theme runs.
|
||||
///
|
||||
/// Anime themes are ninety seconds by convention and rarely stray far. This is
|
||||
/// the corroboration for a name that could go either way: a chapter called
|
||||
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
|
||||
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
|
||||
|
||||
/// Credits can run longer than a theme — a full cast roll over a still — so a
|
||||
/// chapter that only *reads* like an ending gets this much room when it sits at
|
||||
/// the end of the file.
|
||||
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
|
||||
|
||||
/// An opening that far into the file is not an opening. Cold opens run long in
|
||||
/// a first episode, so this is generous.
|
||||
const OPENING_BEFORE_SECONDS: f64 = 480.0;
|
||||
|
||||
/// Where the last part of a file starts, as a fraction of it. A chapter that
|
||||
/// merely reads like credits is taken as credits past this point.
|
||||
const TAIL_FRACTION: f64 = 0.75;
|
||||
|
||||
impl Claim {
|
||||
/// Read a claim out of a chapter name.
|
||||
///
|
||||
/// Matched on whole words from the front, not on substrings: `Operation
|
||||
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
|
||||
/// an ending, and skipping a viewer out of either is a mistake they cannot
|
||||
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
|
||||
/// Opening`) is stepped over first, because plenty of muxes carry it.
|
||||
fn of(title: &str) -> Self {
|
||||
// Japanese names appear verbatim in releases from JP sources, and they
|
||||
// are unambiguous where the English ones are not. Checked before the
|
||||
// word split, which only knows about ASCII and would find no words at
|
||||
// all in a name written entirely in kana.
|
||||
if title.contains("オープニング") {
|
||||
return Self::Opening;
|
||||
}
|
||||
if title.contains("エンディング") {
|
||||
return Self::Ending;
|
||||
}
|
||||
if title.contains("予告") {
|
||||
return Self::Preview;
|
||||
}
|
||||
|
||||
let words = words(title);
|
||||
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let Some((head, rest)) = words.split_first() else {
|
||||
return Self::Content;
|
||||
};
|
||||
let second = rest.first().copied().unwrap_or_default();
|
||||
|
||||
match (*head, second) {
|
||||
// Two-word forms first: the second word is what tells `next
|
||||
// episode` from `next`, and `end credits` from `end`.
|
||||
("next", _) | ("nep", _) => Self::Preview,
|
||||
("end" | "ending" | "closing", "credits") => Self::Ending,
|
||||
("cast" | "staff", "roll" | "credits") => Self::Ending,
|
||||
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
|
||||
("ending" | "ed", "theme" | "song") => Self::Ending,
|
||||
|
||||
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
|
||||
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
|
||||
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
|
||||
|
||||
("intro", _) => Self::MaybeOpening,
|
||||
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
|
||||
_ => Self::Content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chapter name as lowercase words, with any leading numbering dropped.
|
||||
///
|
||||
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
|
||||
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
|
||||
fn words(title: &str) -> Vec<String> {
|
||||
let mut words: Vec<String> = title
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|word| !word.is_empty())
|
||||
.map(|word| {
|
||||
word.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
// A leading token that was *only* digits is numbering, not a name.
|
||||
if words.first().is_some_and(String::is_empty) {
|
||||
words.remove(0);
|
||||
}
|
||||
words.retain(|word| !word.is_empty());
|
||||
words
|
||||
}
|
||||
|
||||
/// The role of every chapter in a file, with the file's own shape taken into
|
||||
/// account.
|
||||
///
|
||||
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
|
||||
/// cannot tell an opening called `Intro` from ten minutes of story called the
|
||||
/// same thing; here the other chapters and the running time settle it:
|
||||
///
|
||||
/// * a chapter that merely *reads* like an opening is one only when the file
|
||||
/// has no chapter that says so outright, it runs the length of a theme song,
|
||||
/// and it is near the front;
|
||||
/// * a chapter that reads like credits is the ending when it runs to about that
|
||||
/// length, or when it sits in the last quarter of the file.
|
||||
///
|
||||
/// Both rules fail *towards content*: not offering a skip costs a button, and
|
||||
/// skipping the viewer out of the story costs them the story.
|
||||
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
|
||||
let claims: Vec<Claim> = chapters
|
||||
.iter()
|
||||
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
|
||||
.collect();
|
||||
let has_real_opening = claims.contains(&Claim::Opening);
|
||||
|
||||
claims
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, claim)| {
|
||||
let length = chapter_end(chapters, index, duration)
|
||||
.map(|end| end - chapters[index].start)
|
||||
.filter(|length| *length > 0.0);
|
||||
let start = chapters[index].start;
|
||||
|
||||
match claim {
|
||||
Claim::Opening => ChapterRole::Opening,
|
||||
Claim::Ending => ChapterRole::Ending,
|
||||
Claim::Preview => ChapterRole::Preview,
|
||||
Claim::MaybeOpening
|
||||
if !has_real_opening
|
||||
&& start <= OPENING_BEFORE_SECONDS
|
||||
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
|
||||
{
|
||||
ChapterRole::Opening
|
||||
}
|
||||
Claim::MaybeEnding
|
||||
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|
||||
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
|
||||
{
|
||||
ChapterRole::Ending
|
||||
}
|
||||
_ => ChapterRole::Content,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where the run of chapters that ends the file — credits, a preview, and
|
||||
/// nothing the viewer would miss — begins.
|
||||
///
|
||||
/// **The suffix, not the last chapter.** A release that puts a Part C after the
|
||||
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
|
||||
/// exactly once) must give up only the preview, or auto-advancing skips a scene
|
||||
/// that matters. Walking back from the end and stopping at the first chapter
|
||||
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
|
||||
/// preview alone, while the ordinary `… ED · Preview` yields both.
|
||||
///
|
||||
/// `None` when the file ends on content, has no chapters, or is *entirely*
|
||||
/// skippable — the last of which is a mis-tagged file, not an episode that is
|
||||
/// all credits.
|
||||
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
|
||||
let first = roles
|
||||
.iter()
|
||||
.rposition(|role| !role.ends_the_episode())
|
||||
.map(|last_content| last_content + 1)?;
|
||||
if first >= chapters.len() {
|
||||
return None;
|
||||
}
|
||||
Some(chapters[first].start)
|
||||
}
|
||||
|
||||
/// Where the chapter starting at `index` ends: the next one's start, or the end
|
||||
/// of the file.
|
||||
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
|
||||
chapters
|
||||
.get(index + 1)
|
||||
.map(|next| next.start)
|
||||
.or(duration)
|
||||
.filter(|end| *end > 0.0)
|
||||
}
|
||||
|
||||
/// The chapter a position falls in.
|
||||
///
|
||||
/// Linear rather than a binary search: a file has a dozen chapters, and this is
|
||||
/// called once a frame at most.
|
||||
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
|
||||
chapters
|
||||
.iter()
|
||||
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
|
||||
Chapter {
|
||||
index,
|
||||
title: Some(title.to_string()),
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_releases_actually_use_are_recognised() {
|
||||
for (name, claim) in [
|
||||
("OP", Claim::Opening),
|
||||
("Opening", Claim::Opening),
|
||||
("OP1 - Again", Claim::Opening),
|
||||
("02 - Opening", Claim::Opening),
|
||||
("Opening Song", Claim::Opening),
|
||||
("NCOP", Claim::Opening),
|
||||
("オープニング", Claim::Opening),
|
||||
("ED", Claim::Ending),
|
||||
("Ending 2", Claim::Ending),
|
||||
("End Credits", Claim::Ending),
|
||||
("Cast Roll", Claim::Ending),
|
||||
("Endcard", Claim::Ending),
|
||||
("エンディング", Claim::Ending),
|
||||
("Preview", Claim::Preview),
|
||||
("Next Episode", Claim::Preview),
|
||||
("Next Episode Preview", Claim::Preview),
|
||||
("予告", Claim::Preview),
|
||||
] {
|
||||
assert_eq!(Claim::of(name), claim, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
|
||||
// The reason this matches words and not prefixes: skipping the viewer
|
||||
// out of "Operation Briefing" would be a bug they cannot undo without
|
||||
// finding the seek bar.
|
||||
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
|
||||
assert_eq!(Claim::of(name), Claim::Content, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The names that cannot be settled by reading them.
|
||||
#[test]
|
||||
fn an_ambiguous_name_is_not_decided_by_itself() {
|
||||
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
|
||||
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
|
||||
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
|
||||
// And a chapter read in isolation never resolves to a skip.
|
||||
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
|
||||
}
|
||||
|
||||
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
|
||||
/// minute episode that begins with a long `Intro` that is the story, and
|
||||
/// ends with a cast scroll. Offering "skip opening" over the first ninety
|
||||
/// seconds of *that* skips the beginning of the series.
|
||||
#[test]
|
||||
fn a_long_intro_is_the_episode_and_not_an_opening() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Intro", 0.0),
|
||||
chapter(1, "Part A", 700.0),
|
||||
chapter(2, "Part B", 2400.0),
|
||||
chapter(3, "Cast", 4680.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(4800.0));
|
||||
assert_eq!(
|
||||
roles[0],
|
||||
ChapterRole::Content,
|
||||
"eleven minutes is not a theme"
|
||||
);
|
||||
assert_eq!(
|
||||
roles[3],
|
||||
ChapterRole::Ending,
|
||||
"a cast roll at the end is one"
|
||||
);
|
||||
// Only the cast scroll is the tail; the episode is not skipped into.
|
||||
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
|
||||
}
|
||||
|
||||
/// The same name in the release that uses it for the theme song: ninety
|
||||
/// seconds, near the front, and no other opening in the file.
|
||||
#[test]
|
||||
fn a_theme_length_intro_near_the_front_is_an_opening() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Avant", 0.0),
|
||||
chapter(1, "Intro", 90.0),
|
||||
chapter(2, "Part A", 180.0),
|
||||
];
|
||||
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
|
||||
|
||||
// …but not when the file already says which chapter the opening is.
|
||||
let chapters = vec![
|
||||
chapter(0, "Intro", 0.0),
|
||||
chapter(1, "OP", 90.0),
|
||||
chapter(2, "Part A", 180.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(roles[0], ChapterRole::Content);
|
||||
assert_eq!(roles[1], ChapterRole::Opening);
|
||||
}
|
||||
|
||||
/// The ordinary shape: the credits and the trailer for next week are one
|
||||
/// run at the end, and the countdown starts where the credits do.
|
||||
#[test]
|
||||
fn the_tail_is_the_whole_run_of_credits_and_preview() {
|
||||
let chapters = vec![
|
||||
chapter(0, "OP", 0.0),
|
||||
chapter(1, "Part A", 90.0),
|
||||
chapter(2, "Part B", 700.0),
|
||||
chapter(3, "ED", 1320.0),
|
||||
chapter(4, "Preview", 1410.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
|
||||
}
|
||||
|
||||
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
|
||||
/// ending, and the preview after that. Only the preview may be skipped —
|
||||
/// treating everything from the ending on as tail would drop a scene.
|
||||
#[test]
|
||||
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Part A", 0.0),
|
||||
chapter(1, "Part B", 700.0),
|
||||
chapter(2, "ED", 1260.0),
|
||||
chapter(3, "Part C", 1350.0),
|
||||
chapter(4, "Preview", 1400.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
|
||||
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
|
||||
}
|
||||
|
||||
/// A file that ends on the story has no tail at all, and one with no
|
||||
/// chapters has nothing to read.
|
||||
#[test]
|
||||
fn a_file_that_ends_on_content_has_no_tail() {
|
||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(credits_start(&chapters, &roles), None);
|
||||
assert_eq!(credits_start(&[], &[]), None);
|
||||
|
||||
// Everything skippable is a mis-tagged file, not an episode that is
|
||||
// entirely credits: auto-advancing out of it at second zero would be
|
||||
// worse than doing nothing.
|
||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
|
||||
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
|
||||
assert_eq!(credits_start(&chapters, &roles), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_position_lands_in_the_chapter_that_contains_it() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Intro", 0.0),
|
||||
chapter(1, "OP", 24.0),
|
||||
chapter(2, "Part A", 114.0),
|
||||
];
|
||||
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
|
||||
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
|
||||
// Exactly on a boundary belongs to the chapter that starts there,
|
||||
// which is what makes "skip" land *out* of the opening rather than at
|
||||
// its last frame.
|
||||
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
|
||||
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
|
||||
let chapters = vec![chapter(0, "OP", 24.0)];
|
||||
assert_eq!(chapter_at(&chapters, 0.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_chapter_ends_where_the_file_does() {
|
||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
||||
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
|
||||
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
|
||||
// No duration yet and nothing after it: there is nowhere to skip to,
|
||||
// and a button that seeks to zero would be worse than no button.
|
||||
assert_eq!(chapter_end(&chapters, 1, None), None);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,25 @@ pub struct AppDirs {
|
||||
}
|
||||
|
||||
impl AppDirs {
|
||||
/// Use directories supplied by a platform host.
|
||||
///
|
||||
/// Android owns its application directories and exposes them through
|
||||
/// `Context`; resolving them through desktop conventions would put state in
|
||||
/// a path that does not exist inside the application sandbox.
|
||||
pub fn from_paths(
|
||||
config: impl Into<PathBuf>,
|
||||
data: impl Into<PathBuf>,
|
||||
cache: impl Into<PathBuf>,
|
||||
) -> Result<Self> {
|
||||
let dirs = Self {
|
||||
config: config.into(),
|
||||
data: data.into(),
|
||||
cache: cache.into(),
|
||||
};
|
||||
dirs.create()?;
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
/// Resolve the platform directories and create them.
|
||||
pub fn ensure() -> Result<Self> {
|
||||
let dirs =
|
||||
@@ -43,10 +62,15 @@ impl AppDirs {
|
||||
cache: dirs.cache_dir().to_path_buf(),
|
||||
};
|
||||
|
||||
for path in [&dirs.config, &dirs.data, &dirs.cache] {
|
||||
dirs.create()?;
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
fn create(&self) -> Result<()> {
|
||||
for path in [&self.config, &self.data, &self.cache] {
|
||||
fs::create_dir_all(path)?;
|
||||
}
|
||||
Ok(dirs)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The share list.
|
||||
@@ -64,6 +88,26 @@ impl AppDirs {
|
||||
self.cache.join("blocks")
|
||||
}
|
||||
|
||||
/// Complete files explicitly kept for disconnected playback.
|
||||
pub fn offline_content(&self) -> PathBuf {
|
||||
self.data.join("offline")
|
||||
}
|
||||
|
||||
/// Opaque path for one complete offline revision. No media name or share
|
||||
/// secret reaches the filesystem.
|
||||
pub fn offline_file(&self, share_id: &str, link_id: &str, revision: &str) -> PathBuf {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(share_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(link_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(revision.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let name: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
self.offline_content().join(format!("{name}.media"))
|
||||
}
|
||||
|
||||
/// Where decrypted poster thumbnails are kept.
|
||||
///
|
||||
/// Separate from the block cache because it is cheap, tiny and wanted on
|
||||
|
||||
@@ -15,6 +15,7 @@ pub enum Error {
|
||||
///
|
||||
/// Boxed because `keyring::Error` is large and this variant is rare; an
|
||||
/// un-boxed one would inflate every `Result` in the crate.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[error("credential store: {0}")]
|
||||
Keyring(#[from] Box<keyring::Error>),
|
||||
|
||||
@@ -31,6 +32,7 @@ pub enum Error {
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl From<keyring::Error> for Error {
|
||||
fn from(error: keyring::Error) -> Self {
|
||||
Self::Keyring(Box::new(error))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod appearance;
|
||||
pub mod catalog;
|
||||
pub mod chapters;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod library;
|
||||
@@ -16,7 +17,9 @@ pub mod prefs;
|
||||
pub mod shares;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use shares::{Share, ShareStore, SharedLibrary};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub use shares::KeyringSecretStore;
|
||||
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary};
|
||||
|
||||
pub use proton_drive_rs;
|
||||
pub use proton_sdk;
|
||||
@@ -18,6 +18,11 @@ use crate::error::Result;
|
||||
/// referenced here because `pstr-core` does not depend on the player.
|
||||
const MAX_VOLUME: f64 = 100.0;
|
||||
|
||||
/// The range of playback rates offered. Past these, audio stops being speech
|
||||
/// and video stops being watchable, and mpv's own resampler gives up first.
|
||||
const MIN_SPEED: f64 = 0.25;
|
||||
const MAX_SPEED: f64 = 4.0;
|
||||
|
||||
/// How playback should sound, and in which language.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
// A file written by an older version, or hand-edited, is missing fields rather
|
||||
@@ -43,6 +48,18 @@ pub struct PlaybackPrefs {
|
||||
/// does and what a season of anything is watched like. Off is one checkbox
|
||||
/// away for someone who falls asleep to it.
|
||||
pub autoplay_next: bool,
|
||||
/// Whether an opening or ending is seeked past rather than offered.
|
||||
///
|
||||
/// Off by default, and deliberately: chapter names are a guess about a file
|
||||
/// somebody else muxed, and a wrong guess that only greys out a button
|
||||
/// costs nothing while a wrong guess that seeks costs the scene.
|
||||
pub auto_skip: bool,
|
||||
/// How fast playback runs, 1.0 being the file's own rate.
|
||||
///
|
||||
/// Kept across episodes deliberately: someone who watches at 1.25 watches
|
||||
/// everything at 1.25, and a speed that reset every episode would be a
|
||||
/// setting nobody could use.
|
||||
pub speed: f64,
|
||||
}
|
||||
|
||||
impl Default for PlaybackPrefs {
|
||||
@@ -54,6 +71,8 @@ impl Default for PlaybackPrefs {
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
autoplay_next: true,
|
||||
auto_skip: false,
|
||||
speed: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +87,10 @@ impl PlaybackPrefs {
|
||||
self.volume = MAX_VOLUME;
|
||||
}
|
||||
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
|
||||
if !self.speed.is_finite() {
|
||||
self.speed = 1.0;
|
||||
}
|
||||
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
|
||||
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
||||
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
||||
self
|
||||
@@ -124,6 +147,8 @@ mod tests {
|
||||
subtitle_language: Some("eng".into()),
|
||||
subtitles: true,
|
||||
autoplay_next: false,
|
||||
auto_skip: true,
|
||||
speed: 1.5,
|
||||
};
|
||||
save(&dirs, &prefs).unwrap();
|
||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||
|
||||
+276
-25
@@ -16,6 +16,7 @@
|
||||
//! regardless of how many links it was given.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
@@ -26,8 +27,55 @@ use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The credential-store service name. One entry per share, keyed by share id.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const KEYRING_SERVICE: &str = "proton-stream";
|
||||
|
||||
/// Secret persistence supplied by the platform.
|
||||
///
|
||||
/// Desktop builds use [`KeyringSecretStore`]. Android supplies an
|
||||
/// implementation backed by a non-exportable Android Keystore key through the
|
||||
/// language bridge, keeping platform APIs out of this portable crate.
|
||||
pub trait SecretStore: Send + Sync {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()>;
|
||||
fn get(&self, key: &str) -> Result<Option<String>>;
|
||||
fn delete(&self, key: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The desktop OS credential store.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeyringSecretStore;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl KeyringSecretStore {
|
||||
fn entry(key: &str) -> Result<keyring::Entry> {
|
||||
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
Self::entry(key)?.set_password(value)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
match Self::entry(key)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<()> {
|
||||
match Self::entry(key)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app records about a share, minus its secrets.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Share {
|
||||
@@ -59,11 +107,17 @@ struct ShareSecrets {
|
||||
/// The configured shares, and their secrets.
|
||||
pub struct ShareStore {
|
||||
dirs: AppDirs,
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl ShareStore {
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn new(dirs: AppDirs) -> Self {
|
||||
Self { dirs }
|
||||
Self::with_secret_store(dirs, Arc::new(KeyringSecretStore))
|
||||
}
|
||||
|
||||
pub fn with_secret_store(dirs: AppDirs, secrets: Arc<dyn SecretStore>) -> Self {
|
||||
Self { dirs, secrets }
|
||||
}
|
||||
|
||||
/// Every configured share, in the order they were added.
|
||||
@@ -106,6 +160,49 @@ impl ShareStore {
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// Re-supply a configured share's secrets, keeping everything else.
|
||||
///
|
||||
/// A secret store can lose the ability to read what it wrote: an Android
|
||||
/// Keystore key is invalidated by a lockscreen change or a device restore,
|
||||
/// and a desktop keyring can be reset. After that every open of the share
|
||||
/// fails — and so, often, does removing it, because removal deletes a
|
||||
/// secret the store can no longer touch. Re-entering the link is the way
|
||||
/// back that does not cost the catalog and the offline files.
|
||||
///
|
||||
/// The link must be the same one: a different token is a different share,
|
||||
/// and quietly repointing an existing library at it would leave every
|
||||
/// catalog row describing files that are no longer there.
|
||||
pub fn replace_secrets(
|
||||
&self,
|
||||
id: &str,
|
||||
url: &str,
|
||||
custom_password: Option<&str>,
|
||||
) -> Result<Share> {
|
||||
let token = token_from_url(url)?;
|
||||
let mut shares = self.list()?;
|
||||
let share = shares
|
||||
.iter_mut()
|
||||
.find(|share| share.id == id)
|
||||
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
|
||||
if share.token != token {
|
||||
return Err(Error::Config(
|
||||
"that link is for a different share; add it as a new one".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let secrets = ShareSecrets {
|
||||
url: url.to_string(),
|
||||
custom_password: custom_password
|
||||
.filter(|password| !password.is_empty())
|
||||
.map(str::to_string),
|
||||
};
|
||||
self.store_secrets(id, &secrets)?;
|
||||
share.has_custom_password = secrets.custom_password.is_some();
|
||||
let updated = share.clone();
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Forget a share and its secrets.
|
||||
///
|
||||
/// The config entry goes first here, for the mirror-image reason: if the
|
||||
@@ -121,10 +218,7 @@ impl ShareStore {
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
|
||||
// A missing entry is the desired end state, not a failure.
|
||||
match self.entry(id)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
self.secrets.delete(id)
|
||||
}
|
||||
|
||||
/// Open a share as a visitor.
|
||||
@@ -143,27 +237,18 @@ impl ShareStore {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn entry(&self, id: &str) -> Result<keyring::Entry> {
|
||||
Ok(keyring::Entry::new(KEYRING_SERVICE, id)?)
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
let encoded = serde_json::to_string(secrets)
|
||||
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
|
||||
self.entry(id)?.set_password(&encoded)?;
|
||||
Ok(())
|
||||
self.secrets.set(id, &encoded)
|
||||
}
|
||||
|
||||
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
|
||||
let encoded = match self.entry(id)?.get_password() {
|
||||
Ok(encoded) => encoded,
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
return Err(Error::NotFound(format!(
|
||||
"share {id} has no stored credentials; remove and re-add it"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let encoded = self.secrets.get(id)?.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"share {id} has no stored credentials; remove and re-add it"
|
||||
))
|
||||
})?;
|
||||
serde_json::from_str(&encoded)
|
||||
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
|
||||
}
|
||||
@@ -181,10 +266,28 @@ impl SharedLibrary {
|
||||
/// the whole library — one revoked link should not make the other three
|
||||
/// unwatchable.
|
||||
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
Self::open_all_reusing(store, BTreeMap::new()).await
|
||||
}
|
||||
|
||||
/// Open every configured share, reusing the clients in `reusable`.
|
||||
///
|
||||
/// Adding or removing one link should not cost a fresh handshake for the
|
||||
/// shares that were already open, so a caller that still holds them can
|
||||
/// hand them back here. A share whose stored secrets changed must *not*
|
||||
/// appear in `reusable` — its old client authenticates against the old
|
||||
/// link.
|
||||
pub async fn open_all_reusing(
|
||||
store: &ShareStore,
|
||||
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
let mut clients = BTreeMap::new();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for share in store.list()? {
|
||||
if let Some(client) = reusable.remove(&share.id) {
|
||||
clients.insert(share.id, client);
|
||||
continue;
|
||||
}
|
||||
match store.open(&share).await {
|
||||
Ok(client) => {
|
||||
clients.insert(share.id.clone(), client);
|
||||
@@ -196,6 +299,23 @@ impl SharedLibrary {
|
||||
Ok((Self { clients }, failures))
|
||||
}
|
||||
|
||||
/// The opened clients, minus `dropping`, ready to hand to
|
||||
/// [`SharedLibrary::open_all_reusing`].
|
||||
///
|
||||
/// The excluded share is the one whose credentials or membership just
|
||||
/// changed; leaving it out is what makes a removed share's live client
|
||||
/// unreachable rather than merely stale.
|
||||
pub fn reusable_clients(
|
||||
&self,
|
||||
dropping: &str,
|
||||
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
|
||||
self.clients
|
||||
.iter()
|
||||
.filter(|(id, _)| id.as_str() != dropping)
|
||||
.map(|(id, client)| (id.clone(), client.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The client for one share.
|
||||
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
|
||||
self.clients.get(share_id)
|
||||
@@ -206,6 +326,17 @@ impl SharedLibrary {
|
||||
self.clients.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Replay a public-link handshake before performing a user-requested
|
||||
/// refresh. Visitor sessions are short-lived, so a cached client is still
|
||||
/// useful for streaming but must not be assumed valid for a later crawl.
|
||||
pub async fn refresh_session(&self, share_id: &str) -> Result<()> {
|
||||
let client = self
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||
client.refresh_session().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk one share's whole subtree, depth first, yielding every node.
|
||||
///
|
||||
/// There is no recursion helper on the visitor path — the authenticated
|
||||
@@ -266,7 +397,7 @@ fn client_configuration() -> ProtonClientConfiguration {
|
||||
fn token_from_url(url: &str) -> Result<String> {
|
||||
let (_, tail) = url
|
||||
.split_once("/urls/")
|
||||
.ok_or_else(|| Error::Config(format!("{url} is not a Proton share URL")))?;
|
||||
.ok_or_else(|| Error::Config("not a Proton share URL".to_owned()))?;
|
||||
|
||||
let token = tail
|
||||
.split(['#', '?'])
|
||||
@@ -275,12 +406,14 @@ fn token_from_url(url: &str) -> Result<String> {
|
||||
.trim_end_matches('/');
|
||||
|
||||
if token.is_empty() {
|
||||
return Err(Error::Config(format!("{url} carries no share token")));
|
||||
return Err(Error::Config(
|
||||
"Proton share URL carries no share token".to_owned(),
|
||||
));
|
||||
}
|
||||
if !url.contains('#') {
|
||||
return Err(Error::Config(format!(
|
||||
"{url} has no #password fragment; copy the full share link"
|
||||
)));
|
||||
return Err(Error::Config(
|
||||
"Proton share URL has no #password fragment; copy the full share link".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(token.to_string())
|
||||
}
|
||||
@@ -322,6 +455,124 @@ mod tests {
|
||||
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_share_errors_never_repeat_the_secret_url_fragment() {
|
||||
const SECRET: &str = "fragment-secret-that-must-not-leak";
|
||||
for url in [
|
||||
format!("https://example.com/not-a-share#{SECRET}"),
|
||||
format!("https://drive.proton.me/urls/#{SECRET}"),
|
||||
] {
|
||||
let error = token_from_url(&url).unwrap_err().to_string();
|
||||
assert!(
|
||||
!error.contains(SECRET),
|
||||
"error exposed URL fragment: {error}"
|
||||
);
|
||||
assert!(
|
||||
!error.contains(&url),
|
||||
"error repeated the full URL: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A store that has forgotten how to read what it wrote, which is what an
|
||||
/// invalidated Android Keystore key looks like from Rust.
|
||||
#[derive(Default)]
|
||||
struct Unreadable {
|
||||
entries: std::sync::Mutex<BTreeMap<String, String>>,
|
||||
readable: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl SecretStore for Unreadable {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_owned(), value.to_owned());
|
||||
self.readable
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
if !self.readable.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(Error::Config("key permanently invalidated".into()));
|
||||
}
|
||||
Ok(self.entries.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<()> {
|
||||
self.entries.lock().unwrap().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn store_in(name: &str) -> (ShareStore, Arc<Unreadable>) {
|
||||
let root = std::env::temp_dir().join(format!("pstr-shares-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dirs = AppDirs::from_paths(root.join("config"), root.join("data"), root.join("cache"))
|
||||
.expect("app directories");
|
||||
let secrets = Arc::new(Unreadable::default());
|
||||
(
|
||||
ShareStore::with_secret_store(dirs, secrets.clone()),
|
||||
secrets,
|
||||
)
|
||||
}
|
||||
|
||||
/// The way back from an unreadable secret store. The share keeps its id, so
|
||||
/// the catalog rows and offline files keyed by it stay valid — which is the
|
||||
/// whole reason this exists rather than Remove-and-re-add.
|
||||
#[test]
|
||||
fn a_re_entered_link_restores_a_share_whose_secret_cannot_be_read() {
|
||||
let (store, secrets) = store_in("repair");
|
||||
let url = "https://drive.proton.me/urls/ABC123#s3cr3t";
|
||||
let added = store.add("Library", url, None).expect("add");
|
||||
|
||||
secrets
|
||||
.readable
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(store.load_secrets(&added.id).is_err());
|
||||
|
||||
let repaired = store
|
||||
.replace_secrets(&added.id, url, Some("new-password"))
|
||||
.expect("replace");
|
||||
assert_eq!(repaired.id, added.id);
|
||||
assert!(repaired.has_custom_password);
|
||||
assert!(
|
||||
store
|
||||
.list()
|
||||
.expect("list")
|
||||
.iter()
|
||||
.any(|share| share.id == added.id && share.has_custom_password)
|
||||
);
|
||||
}
|
||||
|
||||
/// A different link is a different share. Repointing this one at it would
|
||||
/// leave every catalog row and offline file describing files that are not
|
||||
/// there.
|
||||
#[test]
|
||||
fn a_re_entered_link_for_a_different_share_is_refused() {
|
||||
let (store, _) = store_in("repair-mismatch");
|
||||
let added = store
|
||||
.add(
|
||||
"Library",
|
||||
"https://drive.proton.me/urls/ABC123#s3cr3t",
|
||||
None,
|
||||
)
|
||||
.expect("add");
|
||||
|
||||
let error = store
|
||||
.replace_secrets(
|
||||
&added.id,
|
||||
"https://drive.proton.me/urls/DEF456#s3cr3t",
|
||||
None,
|
||||
)
|
||||
.expect_err("a different token must be refused");
|
||||
assert!(
|
||||
error.to_string().contains("different share"),
|
||||
"says what is wrong: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The id must be derivable from the token alone — deriving it from the URL
|
||||
/// would mix a secret into the config file.
|
||||
#[test]
|
||||
|
||||
@@ -14,10 +14,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
# The per-platform keystore feature comes from `pstr-core` above — cargo unifies
|
||||
# it across the graph, and `pstr-core` is a hard dependency here. Do not enable
|
||||
# `keyring` without it: the feature-less build resolves to the `mock` store.
|
||||
keyring.workspace = true
|
||||
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
|
||||
# binary means two TLS stacks linked into it.
|
||||
reqwest.workspace = true
|
||||
|
||||
@@ -223,6 +223,54 @@ impl MetadataService {
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -6,23 +6,18 @@
|
||||
//! and the same rule that keeps share fragments out of the config keeps this out
|
||||
//! too.
|
||||
|
||||
use pstr_core::SecretStore;
|
||||
use pstr_core::config::{AppDirs, read_json, write_json};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The credential-store service name, shared with [`pstr_core::shares`]. Keys
|
||||
/// are namespaced by provider, so two providers' keys can coexist and switching
|
||||
/// back does not mean typing one in again.
|
||||
const KEYRING_SERVICE: &str = "proton-stream";
|
||||
use crate::error::Result;
|
||||
|
||||
fn settings_file(dirs: &AppDirs) -> std::path::PathBuf {
|
||||
dirs.config.join("metadata.json")
|
||||
}
|
||||
|
||||
fn keyring_entry(provider: ProviderId) -> Result<keyring::Entry> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, &format!("metadata-{}", provider.as_str()))
|
||||
.map_err(|error| Error::Config(format!("open the credential store: {error}")))
|
||||
fn secret_key(provider: ProviderId) -> String {
|
||||
format!("metadata-{}", provider.as_str())
|
||||
}
|
||||
|
||||
/// The stored settings, or the defaults on a first run.
|
||||
@@ -42,16 +37,18 @@ pub fn save(dirs: &AppDirs, config: &MetadataConfig) -> Result<()> {
|
||||
/// than as an error: the caller's next move is the same either way — tell the
|
||||
/// viewer the provider needs a key — and a keyring failure phrased as one is a
|
||||
/// worse message than that.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn api_key(provider: ProviderId) -> Option<String> {
|
||||
api_key_in(&pstr_core::KeyringSecretStore, provider)
|
||||
}
|
||||
|
||||
/// The API key through a platform-provided secret store.
|
||||
pub fn api_key_in(secrets: &dyn SecretStore, provider: ProviderId) -> Option<String> {
|
||||
if !provider.needs_api_key() {
|
||||
return None;
|
||||
}
|
||||
match keyring_entry(provider).and_then(|entry| {
|
||||
entry
|
||||
.get_password()
|
||||
.map_err(|error| Error::Config(error.to_string()))
|
||||
}) {
|
||||
Ok(key) => Some(key),
|
||||
match secrets.get(&secret_key(provider)) {
|
||||
Ok(key) => key,
|
||||
Err(error) => {
|
||||
tracing::debug!("no stored API key for {}: {error}", provider.label());
|
||||
None
|
||||
@@ -60,18 +57,19 @@ pub fn api_key(provider: ProviderId) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Store an API key, or forget it when `key` is empty.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn set_api_key(provider: ProviderId, key: &str) -> Result<()> {
|
||||
let entry = keyring_entry(provider)?;
|
||||
let result = if key.trim().is_empty() {
|
||||
match entry.delete_credential() {
|
||||
// Clearing a key that was never set is what the viewer asked for.
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
set_api_key_in(&pstr_core::KeyringSecretStore, provider, key)
|
||||
}
|
||||
|
||||
/// Store an API key through a platform-provided secret store.
|
||||
pub fn set_api_key_in(secrets: &dyn SecretStore, provider: ProviderId, key: &str) -> Result<()> {
|
||||
if key.trim().is_empty() {
|
||||
secrets.delete(&secret_key(provider))?;
|
||||
} else {
|
||||
entry.set_password(key)
|
||||
};
|
||||
result.map_err(|error| Error::Config(format!("store the API key: {error}")))
|
||||
secrets.set(&secret_key(provider), key)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,6 +9,9 @@ rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# For `chapters`, which Android reaches through its own bridge and so cannot
|
||||
# live here. Already in the tree via pstr-stream.
|
||||
pstr-core.workspace = true
|
||||
pstr-stream.workspace = true
|
||||
tokio.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -1,280 +1,19 @@
|
||||
//! The chapters a file was muxed with, and what they are for.
|
||||
//! Reading the chapter list out of the player.
|
||||
//!
|
||||
//! The chapters themselves — what a name claims, what the rest of the file
|
||||
//! settles, where the tail begins — live in [`pstr_core::chapters`], because
|
||||
//! Android reads its chapter list through its own JNI adapter and must reach
|
||||
//! the same verdict as this crate does. Only the mpv read is here.
|
||||
//!
|
||||
//! Read the same way as the track list — `chapter-list/N/title`, one
|
||||
//! sub-property at a time — for the same reason: those are plain strings and
|
||||
//! doubles, and a node walk would buy nothing.
|
||||
//!
|
||||
//! Anime releases mark their opening and ending as chapters, which is the whole
|
||||
//! reason this exists: with them a player can offer to skip the ninety seconds
|
||||
//! everyone skips, and without them it cannot.
|
||||
|
||||
use libmpv2::Mpv;
|
||||
|
||||
/// One chapter of the file.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Chapter {
|
||||
/// Position in the list, which is what mpv's `chapter` property is set to.
|
||||
pub index: i64,
|
||||
/// The name the muxer gave it, if any.
|
||||
pub title: Option<String>,
|
||||
/// Where it starts, in seconds.
|
||||
pub start: f64,
|
||||
}
|
||||
|
||||
impl Chapter {
|
||||
/// What to show in a list. A nameless chapter is still worth an entry —
|
||||
/// it is a place in the file — so it gets its number.
|
||||
pub fn label(&self) -> String {
|
||||
match &self.title {
|
||||
Some(title) => title.clone(),
|
||||
None => format!("Chapter {}", self.index + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// What this chapter is, from its name alone.
|
||||
///
|
||||
/// Prefer [`roles`], which resolves the names that only mean one thing *in
|
||||
/// context* — a chapter called `Intro` is an opening in one release and the
|
||||
/// first ten minutes of the story in another.
|
||||
pub fn role(&self) -> ChapterRole {
|
||||
match Claim::of(self.title.as_deref().unwrap_or_default()) {
|
||||
Claim::Opening => ChapterRole::Opening,
|
||||
Claim::Ending => ChapterRole::Ending,
|
||||
Claim::Preview => ChapterRole::Preview,
|
||||
// Unresolved without the rest of the file: assume it is content,
|
||||
// which is the reading that never skips anything.
|
||||
_ => ChapterRole::Content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of chapter worth treating differently from "part of the episode".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChapterRole {
|
||||
/// The opening titles.
|
||||
Opening,
|
||||
/// The ending titles.
|
||||
Ending,
|
||||
/// A preview of the next episode, after the ending.
|
||||
Preview,
|
||||
/// Anything else, including everything unnamed.
|
||||
Content,
|
||||
}
|
||||
|
||||
impl ChapterRole {
|
||||
/// What a button offering to skip this should say. `None` for a chapter
|
||||
/// nobody wants skipped.
|
||||
pub fn skip_label(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Opening => Some("Skip opening"),
|
||||
Self::Ending => Some("Skip ending"),
|
||||
Self::Preview => Some("Skip preview"),
|
||||
Self::Content => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is something the file could end on without the viewer
|
||||
/// missing anything: credits and a trailer for next week.
|
||||
///
|
||||
/// An *opening* is not, even though it is skippable — a file does not end
|
||||
/// with one, and treating it as tail would make a mis-tagged chapter
|
||||
/// auto-advance out of the episode.
|
||||
pub fn ends_the_episode(self) -> bool {
|
||||
matches!(self, Self::Ending | Self::Preview)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chapter's *name* claims, before the rest of the file is consulted.
|
||||
///
|
||||
/// The two `Maybe` variants are the whole reason this is separate from
|
||||
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
|
||||
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
|
||||
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
|
||||
/// files and a mid-episode scene in a few. Neither can be settled by the name.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Claim {
|
||||
Opening,
|
||||
Ending,
|
||||
Preview,
|
||||
/// Reads like an opening, but only if the file has no real one and it is
|
||||
/// the length of a theme song.
|
||||
MaybeOpening,
|
||||
/// Reads like credits, but only near the end of the file or at theme
|
||||
/// length.
|
||||
MaybeEnding,
|
||||
Content,
|
||||
}
|
||||
|
||||
/// How long an opening or ending theme runs.
|
||||
///
|
||||
/// Anime themes are ninety seconds by convention and rarely stray far. This is
|
||||
/// the corroboration for a name that could go either way: a chapter called
|
||||
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
|
||||
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
|
||||
|
||||
/// Credits can run longer than a theme — a full cast roll over a still — so a
|
||||
/// chapter that only *reads* like an ending gets this much room when it sits at
|
||||
/// the end of the file.
|
||||
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
|
||||
|
||||
/// An opening that far into the file is not an opening. Cold opens run long in
|
||||
/// a first episode, so this is generous.
|
||||
const OPENING_BEFORE_SECONDS: f64 = 480.0;
|
||||
|
||||
/// Where the last part of a file starts, as a fraction of it. A chapter that
|
||||
/// merely reads like credits is taken as credits past this point.
|
||||
const TAIL_FRACTION: f64 = 0.75;
|
||||
|
||||
impl Claim {
|
||||
/// Read a claim out of a chapter name.
|
||||
///
|
||||
/// Matched on whole words from the front, not on substrings: `Operation
|
||||
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
|
||||
/// an ending, and skipping a viewer out of either is a mistake they cannot
|
||||
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
|
||||
/// Opening`) is stepped over first, because plenty of muxes carry it.
|
||||
fn of(title: &str) -> Self {
|
||||
// Japanese names appear verbatim in releases from JP sources, and they
|
||||
// are unambiguous where the English ones are not. Checked before the
|
||||
// word split, which only knows about ASCII and would find no words at
|
||||
// all in a name written entirely in kana.
|
||||
if title.contains("オープニング") {
|
||||
return Self::Opening;
|
||||
}
|
||||
if title.contains("エンディング") {
|
||||
return Self::Ending;
|
||||
}
|
||||
if title.contains("予告") {
|
||||
return Self::Preview;
|
||||
}
|
||||
|
||||
let words = words(title);
|
||||
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let Some((head, rest)) = words.split_first() else {
|
||||
return Self::Content;
|
||||
};
|
||||
let second = rest.first().copied().unwrap_or_default();
|
||||
|
||||
match (*head, second) {
|
||||
// Two-word forms first: the second word is what tells `next
|
||||
// episode` from `next`, and `end credits` from `end`.
|
||||
("next", _) | ("nep", _) => Self::Preview,
|
||||
("end" | "ending" | "closing", "credits") => Self::Ending,
|
||||
("cast" | "staff", "roll" | "credits") => Self::Ending,
|
||||
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
|
||||
("ending" | "ed", "theme" | "song") => Self::Ending,
|
||||
|
||||
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
|
||||
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
|
||||
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
|
||||
|
||||
("intro", _) => Self::MaybeOpening,
|
||||
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
|
||||
_ => Self::Content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chapter name as lowercase words, with any leading numbering dropped.
|
||||
///
|
||||
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
|
||||
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
|
||||
fn words(title: &str) -> Vec<String> {
|
||||
let mut words: Vec<String> = title
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|word| !word.is_empty())
|
||||
.map(|word| {
|
||||
word.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
// A leading token that was *only* digits is numbering, not a name.
|
||||
if words.first().is_some_and(String::is_empty) {
|
||||
words.remove(0);
|
||||
}
|
||||
words.retain(|word| !word.is_empty());
|
||||
words
|
||||
}
|
||||
|
||||
/// The role of every chapter in a file, with the file's own shape taken into
|
||||
/// account.
|
||||
///
|
||||
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
|
||||
/// cannot tell an opening called `Intro` from ten minutes of story called the
|
||||
/// same thing; here the other chapters and the running time settle it:
|
||||
///
|
||||
/// * a chapter that merely *reads* like an opening is one only when the file
|
||||
/// has no chapter that says so outright, it runs the length of a theme song,
|
||||
/// and it is near the front;
|
||||
/// * a chapter that reads like credits is the ending when it runs to about that
|
||||
/// length, or when it sits in the last quarter of the file.
|
||||
///
|
||||
/// Both rules fail *towards content*: not offering a skip costs a button, and
|
||||
/// skipping the viewer out of the story costs them the story.
|
||||
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
|
||||
let claims: Vec<Claim> = chapters
|
||||
.iter()
|
||||
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
|
||||
.collect();
|
||||
let has_real_opening = claims.contains(&Claim::Opening);
|
||||
|
||||
claims
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, claim)| {
|
||||
let length = chapter_end(chapters, index, duration)
|
||||
.map(|end| end - chapters[index].start)
|
||||
.filter(|length| *length > 0.0);
|
||||
let start = chapters[index].start;
|
||||
|
||||
match claim {
|
||||
Claim::Opening => ChapterRole::Opening,
|
||||
Claim::Ending => ChapterRole::Ending,
|
||||
Claim::Preview => ChapterRole::Preview,
|
||||
Claim::MaybeOpening
|
||||
if !has_real_opening
|
||||
&& start <= OPENING_BEFORE_SECONDS
|
||||
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
|
||||
{
|
||||
ChapterRole::Opening
|
||||
}
|
||||
Claim::MaybeEnding
|
||||
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|
||||
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
|
||||
{
|
||||
ChapterRole::Ending
|
||||
}
|
||||
_ => ChapterRole::Content,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where the run of chapters that ends the file — credits, a preview, and
|
||||
/// nothing the viewer would miss — begins.
|
||||
///
|
||||
/// **The suffix, not the last chapter.** A release that puts a Part C after the
|
||||
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
|
||||
/// exactly once) must give up only the preview, or auto-advancing skips a scene
|
||||
/// that matters. Walking back from the end and stopping at the first chapter
|
||||
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
|
||||
/// preview alone, while the ordinary `… ED · Preview` yields both.
|
||||
///
|
||||
/// `None` when the file ends on content, has no chapters, or is *entirely*
|
||||
/// skippable — the last of which is a mis-tagged file, not an episode that is
|
||||
/// all credits.
|
||||
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
|
||||
let first = roles
|
||||
.iter()
|
||||
.rposition(|role| !role.ends_the_episode())
|
||||
.map(|last_content| last_content + 1)?;
|
||||
if first >= chapters.len() {
|
||||
return None;
|
||||
}
|
||||
Some(chapters[first].start)
|
||||
}
|
||||
pub use pstr_core::chapters::{
|
||||
Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles,
|
||||
};
|
||||
|
||||
/// Every chapter of the file mpv currently has open.
|
||||
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
||||
@@ -293,211 +32,3 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where the chapter starting at `index` ends: the next one's start, or the end
|
||||
/// of the file.
|
||||
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
|
||||
chapters
|
||||
.get(index + 1)
|
||||
.map(|next| next.start)
|
||||
.or(duration)
|
||||
.filter(|end| *end > 0.0)
|
||||
}
|
||||
|
||||
/// The chapter a position falls in.
|
||||
///
|
||||
/// Linear rather than a binary search: a file has a dozen chapters, and this is
|
||||
/// called once a frame at most.
|
||||
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
|
||||
chapters
|
||||
.iter()
|
||||
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
|
||||
Chapter {
|
||||
index,
|
||||
title: Some(title.to_string()),
|
||||
start,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_releases_actually_use_are_recognised() {
|
||||
for (name, claim) in [
|
||||
("OP", Claim::Opening),
|
||||
("Opening", Claim::Opening),
|
||||
("OP1 - Again", Claim::Opening),
|
||||
("02 - Opening", Claim::Opening),
|
||||
("Opening Song", Claim::Opening),
|
||||
("NCOP", Claim::Opening),
|
||||
("オープニング", Claim::Opening),
|
||||
("ED", Claim::Ending),
|
||||
("Ending 2", Claim::Ending),
|
||||
("End Credits", Claim::Ending),
|
||||
("Cast Roll", Claim::Ending),
|
||||
("Endcard", Claim::Ending),
|
||||
("エンディング", Claim::Ending),
|
||||
("Preview", Claim::Preview),
|
||||
("Next Episode", Claim::Preview),
|
||||
("Next Episode Preview", Claim::Preview),
|
||||
("予告", Claim::Preview),
|
||||
] {
|
||||
assert_eq!(Claim::of(name), claim, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
|
||||
// The reason this matches words and not prefixes: skipping the viewer
|
||||
// out of "Operation Briefing" would be a bug they cannot undo without
|
||||
// finding the seek bar.
|
||||
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
|
||||
assert_eq!(Claim::of(name), Claim::Content, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The names that cannot be settled by reading them.
|
||||
#[test]
|
||||
fn an_ambiguous_name_is_not_decided_by_itself() {
|
||||
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
|
||||
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
|
||||
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
|
||||
// And a chapter read in isolation never resolves to a skip.
|
||||
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
|
||||
}
|
||||
|
||||
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
|
||||
/// minute episode that begins with a long `Intro` that is the story, and
|
||||
/// ends with a cast scroll. Offering "skip opening" over the first ninety
|
||||
/// seconds of *that* skips the beginning of the series.
|
||||
#[test]
|
||||
fn a_long_intro_is_the_episode_and_not_an_opening() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Intro", 0.0),
|
||||
chapter(1, "Part A", 700.0),
|
||||
chapter(2, "Part B", 2400.0),
|
||||
chapter(3, "Cast", 4680.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(4800.0));
|
||||
assert_eq!(
|
||||
roles[0],
|
||||
ChapterRole::Content,
|
||||
"eleven minutes is not a theme"
|
||||
);
|
||||
assert_eq!(
|
||||
roles[3],
|
||||
ChapterRole::Ending,
|
||||
"a cast roll at the end is one"
|
||||
);
|
||||
// Only the cast scroll is the tail; the episode is not skipped into.
|
||||
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
|
||||
}
|
||||
|
||||
/// The same name in the release that uses it for the theme song: ninety
|
||||
/// seconds, near the front, and no other opening in the file.
|
||||
#[test]
|
||||
fn a_theme_length_intro_near_the_front_is_an_opening() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Avant", 0.0),
|
||||
chapter(1, "Intro", 90.0),
|
||||
chapter(2, "Part A", 180.0),
|
||||
];
|
||||
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
|
||||
|
||||
// …but not when the file already says which chapter the opening is.
|
||||
let chapters = vec![
|
||||
chapter(0, "Intro", 0.0),
|
||||
chapter(1, "OP", 90.0),
|
||||
chapter(2, "Part A", 180.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(roles[0], ChapterRole::Content);
|
||||
assert_eq!(roles[1], ChapterRole::Opening);
|
||||
}
|
||||
|
||||
/// The ordinary shape: the credits and the trailer for next week are one
|
||||
/// run at the end, and the countdown starts where the credits do.
|
||||
#[test]
|
||||
fn the_tail_is_the_whole_run_of_credits_and_preview() {
|
||||
let chapters = vec![
|
||||
chapter(0, "OP", 0.0),
|
||||
chapter(1, "Part A", 90.0),
|
||||
chapter(2, "Part B", 700.0),
|
||||
chapter(3, "ED", 1320.0),
|
||||
chapter(4, "Preview", 1410.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
|
||||
}
|
||||
|
||||
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
|
||||
/// ending, and the preview after that. Only the preview may be skipped —
|
||||
/// treating everything from the ending on as tail would drop a scene.
|
||||
#[test]
|
||||
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Part A", 0.0),
|
||||
chapter(1, "Part B", 700.0),
|
||||
chapter(2, "ED", 1260.0),
|
||||
chapter(3, "Part C", 1350.0),
|
||||
chapter(4, "Preview", 1400.0),
|
||||
];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
|
||||
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
|
||||
}
|
||||
|
||||
/// A file that ends on the story has no tail at all, and one with no
|
||||
/// chapters has nothing to read.
|
||||
#[test]
|
||||
fn a_file_that_ends_on_content_has_no_tail() {
|
||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
||||
let roles = roles(&chapters, Some(1440.0));
|
||||
assert_eq!(credits_start(&chapters, &roles), None);
|
||||
assert_eq!(credits_start(&[], &[]), None);
|
||||
|
||||
// Everything skippable is a mis-tagged file, not an episode that is
|
||||
// entirely credits: auto-advancing out of it at second zero would be
|
||||
// worse than doing nothing.
|
||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
|
||||
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
|
||||
assert_eq!(credits_start(&chapters, &roles), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_position_lands_in_the_chapter_that_contains_it() {
|
||||
let chapters = vec![
|
||||
chapter(0, "Intro", 0.0),
|
||||
chapter(1, "OP", 24.0),
|
||||
chapter(2, "Part A", 114.0),
|
||||
];
|
||||
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
|
||||
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
|
||||
// Exactly on a boundary belongs to the chapter that starts there,
|
||||
// which is what makes "skip" land *out* of the opening rather than at
|
||||
// its last frame.
|
||||
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
|
||||
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
|
||||
let chapters = vec![chapter(0, "OP", 24.0)];
|
||||
assert_eq!(chapter_at(&chapters, 0.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_chapter_ends_where_the_file_does() {
|
||||
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
|
||||
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
|
||||
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
|
||||
// No duration yet and nothing after it: there is nowhere to skip to,
|
||||
// and a button that seeks to zero would be worse than no button.
|
||||
assert_eq!(chapter_end(&chapters, 1, None), None);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub use error::{Error, Result};
|
||||
/// returns it and `StreamSource::open` takes it — and taking a dependency on the
|
||||
/// whole SDK for a newtype is not a reasonable price for that.
|
||||
pub use proton_sdk::ids::NodeUid;
|
||||
pub use reader::{LibraryOpener, RevisionBlocks};
|
||||
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
|
||||
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
||||
pub use source::{RevisionOpener, StreamConfig, StreamSource};
|
||||
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
|
||||
@@ -8,6 +8,7 @@
|
||||
//! one, and the caching layer above would then hold neither of them whole.
|
||||
//! Aligning here means each block is fetched, decrypted and cached exactly once.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -63,6 +64,51 @@ impl BlockSource for RevisionBlocks {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
|
||||
/// can seek normally without a second playback path.
|
||||
pub struct FileBlocks {
|
||||
revision_id: String,
|
||||
path: PathBuf,
|
||||
sizes: Vec<u64>,
|
||||
map: BlockMap,
|
||||
}
|
||||
impl FileBlocks {
|
||||
pub fn new(revision_id: String, path: PathBuf, sizes: Vec<u64>) -> Self {
|
||||
let map = BlockMap::new(&sizes);
|
||||
Self {
|
||||
revision_id,
|
||||
path,
|
||||
sizes,
|
||||
map,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl BlockSource for FileBlocks {
|
||||
fn revision_id(&self) -> &str {
|
||||
&self.revision_id
|
||||
}
|
||||
fn block_sizes(&self) -> &[u64] {
|
||||
&self.sizes
|
||||
}
|
||||
async fn read_block(&self, index: usize) -> Result<Vec<u8>> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let (start, size) = match (self.map.start_of(index), self.map.size_of(index)) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => {
|
||||
return Err(Error::NotFound(format!(
|
||||
"offline block {index} is past the end"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut file = tokio::fs::File::open(&self.path).await?;
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let mut data = vec![0; size as usize];
|
||||
file.read_exact(&mut data).await?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens revisions out of the configured shares.
|
||||
pub struct LibraryOpener {
|
||||
library: Arc<pstr_core::SharedLibrary>,
|
||||
|
||||
@@ -165,6 +165,10 @@ impl StreamSource {
|
||||
uid: uid.clone(),
|
||||
};
|
||||
if let Some(stream) = self.inner.lock().pop(&key) {
|
||||
// Before the ring is cleared, not after: a prefetch still in flight
|
||||
// would otherwise re-insert into the ring this just emptied, and
|
||||
// keep downloading for a stream nobody is reading.
|
||||
stream.cancel_read_ahead();
|
||||
self.inner.ring.forget(uid, stream.revision_id());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ struct Inner {
|
||||
}
|
||||
|
||||
impl VideoStream {
|
||||
/// Build a seekable stream from complete local content.
|
||||
pub fn offline(uid: NodeUid, blocks: SharedBlocks, ring_bytes: u64) -> Self {
|
||||
Self::new(uid, blocks, Arc::new(BlockRing::new(ring_bytes)), None, 0)
|
||||
}
|
||||
pub(crate) fn new(
|
||||
uid: NodeUid,
|
||||
blocks: SharedBlocks,
|
||||
@@ -255,20 +259,12 @@ impl VideoStream {
|
||||
/// wants an aborted block simply refetches it — the single-flight entry is
|
||||
/// released by its guard when the task is dropped, so nothing waits on a
|
||||
/// call that will never finish.
|
||||
fn cancel_read_ahead(&self) {
|
||||
let tasks = std::mem::take(&mut *self.inner.tasks());
|
||||
let mut cancelled = 0_u64;
|
||||
for task in tasks {
|
||||
if !task.is_finished() {
|
||||
task.abort();
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
if cancelled > 0 {
|
||||
self.inner
|
||||
.readahead_cancelled
|
||||
.fetch_add(cancelled, Ordering::Relaxed);
|
||||
}
|
||||
///
|
||||
/// Public because a seek is not the only reason to stop speculating: a
|
||||
/// stream nobody is reading any more should not keep spending a phone's
|
||||
/// data plan, which is what [`crate::StreamSource::close`] uses this for.
|
||||
pub fn cancel_read_ahead(&self) {
|
||||
self.inner.cancel_read_ahead();
|
||||
}
|
||||
|
||||
/// Queue the next few blocks after `from`.
|
||||
@@ -296,10 +292,15 @@ impl VideoStream {
|
||||
};
|
||||
|
||||
inner.readahead_started.fetch_add(1, Ordering::Relaxed);
|
||||
let spawned = Arc::clone(inner);
|
||||
// Weak, so that a queue of prefetches cannot keep the stream they
|
||||
// are speculating for alive — see `impl Drop for Inner`.
|
||||
let spawned = Arc::downgrade(inner);
|
||||
let task = tokio::spawn(async move {
|
||||
let _slot = slot;
|
||||
if let Err(e) = block_at(spawned, index).await {
|
||||
let Some(inner) = spawned.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = block_at(inner, index).await {
|
||||
tracing::debug!(index, error = %e, "read-ahead block failed");
|
||||
}
|
||||
});
|
||||
@@ -348,6 +349,39 @@ impl Inner {
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn cancel_read_ahead(&self) {
|
||||
let tasks = std::mem::take(&mut *self.tasks());
|
||||
let mut cancelled = 0_u64;
|
||||
for task in tasks {
|
||||
if !task.is_finished() {
|
||||
task.abort();
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
if cancelled > 0 {
|
||||
self.readahead_cancelled
|
||||
.fetch_add(cancelled, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream nobody holds any more stops speculating.
|
||||
///
|
||||
/// Read-ahead tasks hold a [`Weak`] rather than an [`Arc`], so their existence
|
||||
/// does not keep this alive and this can run while they are still in flight —
|
||||
/// which is the point. Without it, closing the player mid-episode leaves up to a
|
||||
/// full read-ahead window downloading for a stream nobody is watching, and those
|
||||
/// blocks then evict the ones the *next* stream has already fetched.
|
||||
///
|
||||
/// A prefetch that has already upgraded its handle keeps this alive for the one
|
||||
/// block it is fetching. That block is the residue; the window behind it is not.
|
||||
impl Drop for Inner {
|
||||
fn drop(&mut self) {
|
||||
for task in std::mem::take(&mut *self.tasks()) {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one block: memory, then disk, then the network.
|
||||
@@ -787,6 +821,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Closing the player mid-episode must stop the speculation too. On a phone
|
||||
/// on cellular this is up to a full read-ahead window downloaded for a
|
||||
/// stream nobody is watching — and those blocks then evict the ones the next
|
||||
/// stream has already fetched.
|
||||
#[tokio::test]
|
||||
async fn abandoning_a_stream_stops_its_read_ahead() {
|
||||
let fake = Fake::slow(&[1024 * 1024; 200], std::time::Duration::from_millis(200));
|
||||
let stream = stream_over(Arc::clone(&fake), 4);
|
||||
|
||||
stream.read_range(0, 1_000).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert!(stream.stats().readahead_blocks > 0, "nothing to cancel");
|
||||
let started = fake.fetches();
|
||||
|
||||
drop(stream);
|
||||
// Long enough that every prefetch in flight would have completed.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
assert!(
|
||||
fake.fetches() <= started + 1,
|
||||
"read-ahead outlived the stream: {} fetches after {started}",
|
||||
fake.fetches(),
|
||||
);
|
||||
}
|
||||
|
||||
/// And the mirror image: steady playback must not keep throwing its own
|
||||
/// read-ahead away.
|
||||
#[tokio::test]
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user