Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3dcbec1f2 | ||
|
|
99abc8f205 | ||
|
|
3638e0e5df | ||
|
|
aef182e7e8 | ||
|
|
6853725ca0 | ||
|
|
4c80076762 | ||
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 | ||
|
|
0563c91b9e | ||
|
|
3b9f4b549c | ||
|
|
d5c819ee94 | ||
|
|
153859a735 | ||
|
|
1c61aadb22 | ||
|
|
8d40b211ed | ||
|
|
6229a59561 | ||
|
|
cfd654b267 | ||
|
|
0bc7cba428 | ||
|
|
65ecc099b6 | ||
|
|
65a6ae9373 | ||
|
|
2fbbd0e090 | ||
|
|
eee45bc8f8 | ||
|
|
1c8df80c27 | ||
|
|
5673a44310 | ||
|
|
c63f08ac91 | ||
|
|
f2e69dc852 | ||
|
|
8a1807c262 | ||
|
|
47b33cdb91 | ||
|
|
2d7d895dda | ||
|
|
0d33799199 | ||
|
|
2ac906cf37 | ||
|
|
cf065fc5fe | ||
|
|
55ee412a1b | ||
|
|
2275df40a8 | ||
|
|
78ffafe21a | ||
|
|
6e09129d0c | ||
|
|
476fec8c04 | ||
|
|
7af0969e38 | ||
|
|
3df0644bd8 | ||
|
|
5d079b05c9 | ||
|
|
4d44db0949 | ||
|
|
4a54af3245 | ||
|
|
7e734dbf19 | ||
|
|
e7eeaa518f | ||
|
|
39866bee8d | ||
|
|
85092fa893 | ||
|
|
2128a2d705 | ||
|
|
558446b9e4 | ||
|
|
6518aef5ee | ||
|
|
9037e6c2d5 | ||
|
|
d3325843a9 | ||
|
|
79c3f6885d | ||
|
|
a432a93cf9 | ||
|
|
935a985589 | ||
|
|
7d03736954 | ||
|
|
53a1d0afda | ||
|
|
959547cdc8 | ||
|
|
c60ffc7ab1 | ||
|
|
c5808a5e50 | ||
|
|
41d1fbf073 | ||
|
|
ff362d0d08 | ||
|
|
4027ab5fb3 | ||
|
|
c5adcddb1e | ||
|
|
8f78f5c1b1 | ||
|
|
6f28a9c377 | ||
|
|
d3c5f071fb | ||
|
|
ad24557588 | ||
|
|
4e6778e1ea | ||
|
|
dc85890a9f | ||
|
|
4ef62255c0 | ||
|
|
1a9a4624f8 | ||
|
|
cb03f96eca | ||
|
|
46bb68e3a5 | ||
|
|
f1b3ad9637 |
No files matched your search
@@ -3,7 +3,15 @@ name: Android
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['v*']
|
# A `v*` tag reaches this workflow through release.yml, which calls it and
|
||||||
|
# publishes the signed APK. Triggering on the tag here as well would build
|
||||||
|
# the release twice.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
signed_release:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'android/**'
|
- 'android/**'
|
||||||
@@ -31,8 +39,8 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
ANDROID_API: '36'
|
ANDROID_API: '36'
|
||||||
ANDROID_BUILD_TOOLS: '37.0.0'
|
ANDROID_BUILD_TOOLS: '37.0.0'
|
||||||
ANDROID_NDK: '29.0.14206865'
|
ANDROID_NDK: '30.0.16248370'
|
||||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -74,14 +82,23 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||||
|
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||||
|
# build needs are installed explicitly below.
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Install native build prerequisites
|
- name: Install native build prerequisites
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends \
|
sudo apt-get install --no-install-recommends \
|
||||||
autoconf automake build-essential cmake curl git gperf libtool \
|
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||||
|
# mpv-android revision builds requires >= 1.11.
|
||||||
|
pipx install meson==1.12.1
|
||||||
|
|
||||||
- name: Install Android SDK and NDK
|
- name: Install Android SDK and NDK
|
||||||
run: >-
|
run: >-
|
||||||
@@ -90,12 +107,53 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
|
# `release` tree under it before saving, so with its target caching on
|
||||||
|
# every run rebuilt both ABIs from scratch.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
with:
|
||||||
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# The target directory as is: the host build the UniFFI bindings come
|
||||||
|
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||||
|
# build compiles the same crates for the same targets. A new lockfile
|
||||||
|
# starts from the last one's tree, so a version bump rebuilds only the
|
||||||
|
# workspace crates.
|
||||||
|
- id: target
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
# libmpv and its dependency tree are the slowest part of the build, and
|
||||||
|
# depend only on the pinned revision and the build script, which stamps
|
||||||
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
|
# and saved in steps of its own, so a later failure in Gradle does not
|
||||||
|
# discard it the way a post-job cache save would.
|
||||||
|
- id: libmpv
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||||
|
|
||||||
|
- name: Build libmpv
|
||||||
|
run: bash scripts/build-libmpv-android.sh
|
||||||
|
|
||||||
|
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
@@ -124,9 +182,15 @@ jobs:
|
|||||||
- name: Android checks and artifacts
|
- name: Android checks and artifacts
|
||||||
run: bash scripts/build-android.sh all
|
run: bash scripts/build-android.sh all
|
||||||
|
|
||||||
|
- if: steps.target.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||||
|
|
||||||
signed-release:
|
signed-release:
|
||||||
name: Signed release
|
name: Signed release
|
||||||
if: false
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -141,14 +205,23 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||||
|
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||||
|
# build needs are installed explicitly below.
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Install native build prerequisites
|
- name: Install native build prerequisites
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends \
|
sudo apt-get install --no-install-recommends \
|
||||||
autoconf automake build-essential cmake curl git gperf libtool \
|
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||||
|
# mpv-android revision builds requires >= 1.11.
|
||||||
|
pipx install meson==1.12.1
|
||||||
|
|
||||||
- name: Install Android SDK and NDK
|
- name: Install Android SDK and NDK
|
||||||
run: >-
|
run: >-
|
||||||
@@ -157,12 +230,53 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
|
# `release` tree under it before saving, so with its target caching on
|
||||||
|
# every run rebuilt both ABIs from scratch.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
with:
|
||||||
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# The target directory as is: the host build the UniFFI bindings come
|
||||||
|
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||||
|
# build compiles the same crates for the same targets. A new lockfile
|
||||||
|
# starts from the last one's tree, so a version bump rebuilds only the
|
||||||
|
# workspace crates.
|
||||||
|
- id: target
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
# libmpv and its dependency tree are the slowest part of the build, and
|
||||||
|
# depend only on the pinned revision and the build script, which stamps
|
||||||
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
|
# and saved in steps of its own, so a later failure in Gradle does not
|
||||||
|
# discard it the way a post-job cache save would.
|
||||||
|
- id: libmpv
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||||
|
|
||||||
|
- name: Build libmpv
|
||||||
|
run: bash scripts/build-libmpv-android.sh
|
||||||
|
|
||||||
|
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
@@ -188,10 +302,30 @@ jobs:
|
|||||||
- name: Build signed APK and AAB
|
- name: Build signed APK and AAB
|
||||||
run: bash scripts/build-android.sh signed
|
run: bash scripts/build-android.sh signed
|
||||||
|
|
||||||
|
- if: steps.target.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||||
|
|
||||||
|
# The APK bundles a GPL libmpv, so its corresponding source ships next to
|
||||||
|
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||||
|
- name: Stage release files
|
||||||
|
run: |
|
||||||
|
version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||||
|
mkdir -p dist/android
|
||||||
|
cp android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
"dist/android/proton-stream-$version-android.apk"
|
||||||
|
cp android/app/build/generated/mpv/mpv-android-source-*.tar.gz dist/android/
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-signed
|
name: android
|
||||||
path: |
|
path: dist/android/*
|
||||||
android/app/build/outputs/apk/release/*.apk
|
if-no-files-found: error
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-bundle
|
||||||
|
path: android/app/build/outputs/bundle/release/*.aab
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -20,9 +20,6 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Android remains a workflow artifact only until the native libmpv adapter,
|
|
||||||
# corresponding-source bundle, and on-device verification are complete.
|
|
||||||
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
@@ -60,11 +57,6 @@ jobs:
|
|||||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
||||||
|
|
||||||
- name: Android publication remains blocked
|
|
||||||
run: |
|
|
||||||
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
|
|
||||||
echo "Android APK/AAB publication is blocked until native playback is release-ready."
|
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
name: Linux artifacts
|
name: Linux artifacts
|
||||||
needs: verify
|
needs: verify
|
||||||
@@ -148,25 +140,38 @@ jobs:
|
|||||||
dist/*.msi
|
dist/*.msi
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The signed APK plus the corresponding source of the libmpv it bundles. The
|
||||||
|
# keystore and its passwords come from the ANDROID_RELEASE_* secrets.
|
||||||
|
android:
|
||||||
|
name: Android
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/android.yml
|
||||||
|
with:
|
||||||
|
signed_release: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish desktop artifacts
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows]
|
needs: [linux, windows, android]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
# By name: the Android workflow also uploads the AAB, which is for Play
|
||||||
|
# and does not belong on the release.
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
|
name: linux
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Refuse mobile artifacts while Android publishing is blocked
|
|
||||||
run: |
|
|
||||||
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
|
|
||||||
echo "::error::Android publishing is disabled while native playback remains incomplete"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -20,3 +20,9 @@
|
|||||||
/packaging/pkg/
|
/packaging/pkg/
|
||||||
/packaging/src/
|
/packaging/src/
|
||||||
/packaging/*.pkg.tar.zst
|
/packaging/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# iOS generated state: XcodeGen owns the project and the Info.plist, and
|
||||||
|
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
|
||||||
|
/ios/build/
|
||||||
|
/ios/*.xcodeproj/
|
||||||
|
/ios/ProtonStream/Info.plist
|
||||||
@@ -119,12 +119,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android-activity"
|
name = "android-activity"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -609,6 +603,12 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1029,6 +1029,36 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"block",
|
||||||
|
"cocoa-foundation",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics 0.22.3",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa-foundation"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"block",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics-types",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -1136,6 +1166,19 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-graphics"
|
||||||
|
version = "0.22.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics-types",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics"
|
name = "core-graphics"
|
||||||
version = "0.23.2"
|
version = "0.23.2"
|
||||||
@@ -1145,7 +1188,7 @@ dependencies = [
|
|||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1377,6 +1420,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dbus-crossroads"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8"
|
||||||
|
dependencies = [
|
||||||
|
"dbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus-secret-service"
|
name = "dbus-secret-service"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -1709,6 +1761,15 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "egui-phosphor"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
|
||||||
|
dependencies = [
|
||||||
|
"egui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-wgpu"
|
name = "egui-wgpu"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@@ -2003,12 +2064,6 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2024,6 +2079,15 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2031,7 +2095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2045,6 +2109,12 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2279,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"log",
|
"log",
|
||||||
"peniko",
|
"peniko",
|
||||||
@@ -2403,6 +2473,25 @@ dependencies = [
|
|||||||
"euclid",
|
"euclid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -2436,24 +2525,13 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash 0.1.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2462,7 +2540,7 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2592,6 +2670,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -3150,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.18.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3163,6 +3242,15 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3483,6 +3571,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||||
|
dependencies = [
|
||||||
|
"malloc_buf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -4202,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.4.0"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b162681255e296b48ef7261cad899ffb384756e866a1c6d7b7c791cbbc8af3ac"
|
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -4229,13 +4326,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.4.0"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32d9b94ec901edb0e849238ea833121f2fb173308b4927b8aa6b157643fa0a73"
|
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4262,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4281,11 +4378,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"dbus",
|
||||||
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
"egui-phosphor",
|
||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4293,17 +4394,22 @@ dependencies = [
|
|||||||
"pstr-meta",
|
"pstr-meta",
|
||||||
"pstr-player",
|
"pstr-player",
|
||||||
"pstr-stream",
|
"pstr-stream",
|
||||||
|
"raw-window-handle",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"skrifa",
|
||||||
|
"souvlaki",
|
||||||
|
"tauri-winrt-notification",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4321,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4339,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4352,12 +4458,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
"libmpv2",
|
"libmpv2",
|
||||||
"libmpv2-sys",
|
"libmpv2-sys",
|
||||||
|
"pstr-core",
|
||||||
"pstr-stream",
|
"pstr-stream",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4366,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4646,6 +4753,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -5400,6 +5508,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "souvlaki"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"block",
|
||||||
|
"cocoa",
|
||||||
|
"core-graphics 0.22.3",
|
||||||
|
"dbus",
|
||||||
|
"dbus-crossroads",
|
||||||
|
"dispatch",
|
||||||
|
"objc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"windows 0.44.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
@@ -5488,6 +5614,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"windows 0.62.2",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5643,6 +5780,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -6538,6 +6676,36 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.44.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -6551,6 +6719,17 @@ dependencies = [
|
|||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -6579,6 +6758,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6714,6 +6903,24 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-version"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6910,7 +7117,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics",
|
"core-graphics 0.23.2",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "1.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.4.0" }
|
proton-sdk = { version = "0.7.2" }
|
||||||
proton-drive-rs = { version = "0.4.0" }
|
proton-drive-rs = { version = "0.7.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -51,6 +51,8 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
# Local dates for the history page. Already in the tree through the SDK.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
|
|||||||
"x11",
|
"x11",
|
||||||
] }
|
] }
|
||||||
egui = "0.35"
|
egui = "0.35"
|
||||||
|
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
|
||||||
|
# font has them, at its own size and baseline; an icon font is one set.
|
||||||
|
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
|
||||||
|
# Tests only: reads the bundled fonts' character maps. The version epaint
|
||||||
|
# already builds, so it costs no extra compile.
|
||||||
|
skrifa = { version = "0.42", default-features = false, features = ["std"] }
|
||||||
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
||||||
# pulls in every exotic format is weight this app has no use for.
|
# pulls in every exotic format is weight this app has no use for.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
@@ -84,9 +92,20 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
# behind a poisoned lock for the rest of the session. Same reason
|
# behind a poisoned lock for the rest of the session. Same reason
|
||||||
# `proton-drive-linux` uses it.
|
# `proton-drive-linux` uses it.
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows.
|
||||||
|
# `dbus` rather than `zbus` because the keyring's Secret Service store already
|
||||||
|
# links libdbus, so this adds no second D-Bus stack.
|
||||||
|
souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] }
|
||||||
|
# Holding the screensaver off while a film plays. mpv does that for a window of
|
||||||
|
# its own, but the picture here is drawn into ours, and mpv has no window to
|
||||||
|
# hold it for.
|
||||||
|
dbus = "0.9"
|
||||||
|
windows-sys = "0.61"
|
||||||
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
|
tauri-winrt-notification = "0.8"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.12"
|
lru = "0.18"
|
||||||
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
||||||
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
# 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.
|
# be tested against a fake without a network or an account.
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Android plan
|
||||||
|
|
||||||
|
Where the Android client stands against the desktop app, and what to change to
|
||||||
|
make it feel like a modern streaming app rather than a settings form with a
|
||||||
|
player attached. Written 2026-10-03 from a source review and four screenshots
|
||||||
|
(Library, Title, Shares, Settings) taken on a phone running v0.5.0.
|
||||||
|
|
||||||
|
Android last caught up with the desktop on 2026-08-10 (`4ef6225`); the desktop
|
||||||
|
gained about twenty features on 2026-10-02 that Android does not have.
|
||||||
|
|
||||||
|
## Status (2026-10-03)
|
||||||
|
|
||||||
|
Done: section 0, every parity item, and every recommendation in section 2 —
|
||||||
|
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
|
||||||
|
bars drawn edge to edge, search behind an icon, skeleton loading and
|
||||||
|
generated placeholder art, Settings sub-pages and a language picker, a
|
||||||
|
long-press Change match, Inter, light and tablet screenshots, the library
|
||||||
|
and a title side by side from 840 dp, a poster-to-backdrop shared element
|
||||||
|
transition, and predictive back on the title page.
|
||||||
|
|
||||||
|
Two departures from the text below. The tablet layout is a two-pane `Row`
|
||||||
|
rather than `ListDetailPaneScaffold`, which would have added the
|
||||||
|
`adaptive-layout` artifact for one split. Pull to refresh shows the
|
||||||
|
indicator while a crawl runs, but not the crawl's progress.
|
||||||
|
|
||||||
|
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||||
|
tests) passes, and Android lint runs in CI.
|
||||||
|
|
||||||
|
## 0. CI (done)
|
||||||
|
|
||||||
|
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||||
|
`pstr-core` pulls `keyring` → `dbus-secret-service` → `libdbus-sys`, which needs
|
||||||
|
`libdbus-1-dev` on the host. Because no run ever succeeded, no cache was ever
|
||||||
|
saved, so each run started from nothing.
|
||||||
|
|
||||||
|
- `libdbus-1-dev` added to both jobs' apt install.
|
||||||
|
- `Swatinem/rust-cache` shares one key between the two jobs and saves on failure.
|
||||||
|
- `android/app/build/generated/mpv` is cached on the hash of
|
||||||
|
`scripts/build-libmpv-android.sh`; the script writes a `STAMP` (revision plus
|
||||||
|
its own hash) last and skips the whole libmpv build when the stamp matches.
|
||||||
|
|
||||||
|
Later runs also needed meson 1.12.1 from PyPI (noble's 1.3.2 is too old for
|
||||||
|
the pinned fontconfig), libmpv built and cached in its own steps so a later
|
||||||
|
failure keeps it, and lint's `OldTargetApi` made informational. A cached run
|
||||||
|
restores libmpv in under a minute.
|
||||||
|
|
||||||
|
## 1. Desktop parity
|
||||||
|
|
||||||
|
Ordered by value on a phone.
|
||||||
|
|
||||||
|
1. **Open share links from outside the app.** The manifest has only the
|
||||||
|
launcher intent. Add an `ACTION_VIEW` filter for `drive.proton.me/urls/*` and
|
||||||
|
an `ACTION_SEND` (`text/plain`) filter; both open the add-share dialog with
|
||||||
|
the link filled in. Desktop: `4a54af3`, `53a1d0a`.
|
||||||
|
2. **Streaming cache budget.** Android runs on the fixed 4 GiB default
|
||||||
|
(`DiskCacheConfig::DEFAULT_BUDGET_BYTES`), large for a phone. Expose
|
||||||
|
`DiskCache::set_budget` through `pstr-android`, add a 1–16 GiB choice with a
|
||||||
|
usage readout under Storage. Desktop: `a432a93`.
|
||||||
|
3. **Hardware-decoding switch.** `pstr_mpv.cpp` hard-codes
|
||||||
|
`hwdec=auto-copy-safe`; a device with green frames has no way out. A
|
||||||
|
Settings toggle, applied from the next file.
|
||||||
|
4. **Episode list in the player.** A bottom sheet (portrait) or side panel
|
||||||
|
(landscape) with the title's episodes, opened on the playing season and
|
||||||
|
scrolled to the playing episode. Desktop: `7e734db`.
|
||||||
|
5. **Long-press menu on tiles.** Play/resume, mark watched or unwatched,
|
||||||
|
download, change match, remove from Continue watching, in a
|
||||||
|
`ModalBottomSheet`. Desktop: `c5808a5`.
|
||||||
|
6. **Undo on snackbars** for marking a title and leaving Continue watching.
|
||||||
|
`AppUiState.message` is a bare string today; it needs an action. Desktop:
|
||||||
|
`79c3f68`.
|
||||||
|
7. **History tab.** Needs a bridge call over the `pstr-core::library` query
|
||||||
|
the desktop page uses. Desktop: `e7eeaa5`.
|
||||||
|
8. **Buffered range on the seek bar** from mpv's `demuxer-cache-time`.
|
||||||
|
9. **Up-next card** with the next episode's still and a countdown ring instead
|
||||||
|
of "Up next in 7s" text.
|
||||||
|
10. **Featured banner** at the top of the library. Desktop: `7d03736`.
|
||||||
|
11. **Bundled Inter** in `ui/theme/Type.kt`, so both clients read the same.
|
||||||
|
Desktop: `558446b`.
|
||||||
|
|
||||||
|
## 2. UI recommendations
|
||||||
|
|
||||||
|
### Everywhere
|
||||||
|
|
||||||
|
- **Colours stay the theme's.** The red is the Persona theme's accent and the
|
||||||
|
card tint comes from the shared palette, so the redesign changes layout and
|
||||||
|
button hierarchy, not colours.
|
||||||
|
- **One button hierarchy.** At most one filled accent button per screen;
|
||||||
|
secondary actions tonal or outlined; destructive actions behind a menu and a
|
||||||
|
confirmation, never a filled red button in a list row.
|
||||||
|
- **Edge-to-edge, per-screen titles.** The "proton-stream" app bar on every
|
||||||
|
page wastes a band of height. Library uses a collapsing large title; Title
|
||||||
|
pages draw the backdrop under the status bar; other tabs use their name.
|
||||||
|
- **Skeletons instead of spinners** for library and title loading, and
|
||||||
|
generated placeholder art (gradient from the accent plus the title's
|
||||||
|
initials) instead of a black card with a play triangle (Akira).
|
||||||
|
- **Pull-to-refresh** (`PullToRefreshBox`) replaces the top-bar refresh icon,
|
||||||
|
with crawl progress shown in the indicator area.
|
||||||
|
- **Split `ui/ProtonStreamApp.kt`** (1665 lines) into one file per screen
|
||||||
|
before the work below; every item touches it.
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
- Poster grid (2:3, `posterUrl`) at three columns on a phone, title under the
|
||||||
|
poster with no card body. The current 210 dp cropped backdrops fit two titles
|
||||||
|
per screen.
|
||||||
|
- Continue watching: 16:9 still with the progress bar laid over the bottom
|
||||||
|
edge of the image and "S03E11 · 12 min left" under it.
|
||||||
|
- Search becomes an icon in the app bar that expands into an M3 `SearchBar`,
|
||||||
|
instead of a permanent field above everything.
|
||||||
|
- Fix "1 episodes" (line ~507; the season header already pluralises).
|
||||||
|
|
||||||
|
### Title page
|
||||||
|
|
||||||
|
- Full-bleed backdrop with a gradient scrim, poster, title and meta chips
|
||||||
|
(year, score, genres) over it, collapsing as the page scrolls.
|
||||||
|
- Synopsis clamped to three lines with "More".
|
||||||
|
- One full-width primary button, "Resume S03E11" or "Play S01E01", then a row
|
||||||
|
of icon actions: download, mark watched, and a ⋮ menu holding Start over,
|
||||||
|
Change match and More on AniList. Today five buttons of three styles wrap
|
||||||
|
over three lines.
|
||||||
|
- Drop the "Preferred tracks" form and its Save button from this page. The
|
||||||
|
player already remembers tracks by name per title (`d3c5f07`); expose any
|
||||||
|
override from the player's track chooser.
|
||||||
|
- Season picker as a dropdown chip or tabs, not stacked accordions; a 40
|
||||||
|
episode title is one long scroll today.
|
||||||
|
- Episode rows: 16:9 still with progress overlay, "1 · Mother and Children"
|
||||||
|
(provider name, else "Episode 1") rather than the raw file name
|
||||||
|
`[Judas] Oshi no Ko - S01E01.mkv`, runtime or air date as supporting text,
|
||||||
|
watched and download state as small trailing icons. The red `S01E01` label
|
||||||
|
above each row goes.
|
||||||
|
|
||||||
|
### Shares
|
||||||
|
|
||||||
|
- `ListItem` rows: name, "12 titles · crawled 2 h ago" or the failure, and a ⋮
|
||||||
|
menu with Refresh, Re-enter link and Remove (confirmed). The current row
|
||||||
|
wraps "Custom password stored securely" over three lines beside three
|
||||||
|
differently styled actions.
|
||||||
|
- "Add share" as an extended FAB, which is also where the share-link intent
|
||||||
|
from parity item 1 lands.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- Grouped `ListItem` rows (headline, one line of supporting text, trailing
|
||||||
|
switch), with the long explanations moved into the supporting line or an
|
||||||
|
info dialog. Today each toggle carries a paragraph.
|
||||||
|
- Split into sub-pages once storage and decoding settings land: Playback,
|
||||||
|
Downloads & storage, Appearance, Metadata, About.
|
||||||
|
- Language fields become a picker dialog over a language list, not free text.
|
||||||
|
- Storage shows a usage bar (cache, offline episodes) above the budget choice.
|
||||||
|
|
||||||
|
### Robustness
|
||||||
|
|
||||||
|
- Screenshot tests (Roborazzi, JVM, no device) for each screen in light and
|
||||||
|
dark, phone and tablet, at font scale 1.0 and 1.5 — the redesign changes
|
||||||
|
every screen, and `docs/TESTING.md` has no visual layer today.
|
||||||
|
- 48 dp minimum touch targets and content descriptions on the new icon-only
|
||||||
|
actions.
|
||||||
|
- Tablet: `ListDetailPaneScaffold` for library + title, since
|
||||||
|
`NavigationSuiteScaffold` already switches to a rail there.
|
||||||
|
- Shared-element transition from a poster to its title page, and predictive
|
||||||
|
back.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. CI green (section 0).
|
||||||
|
2. Split `ProtonStreamApp.kt`; add screenshot tests for the current screens.
|
||||||
|
3. Parity 1–3 (small, phone-specific fixes).
|
||||||
|
4. Button hierarchy and Inter — every later screen inherits them.
|
||||||
|
5. Title page, then Library, then Shares and Settings.
|
||||||
|
6. Parity 4–10.
|
||||||
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
One window at a time: a second launch brings the open window forward and
|
||||||
|
hands it any share link it was given, then exits.
|
||||||
|
|
||||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||||
is broken:
|
is broken:
|
||||||
|
|
||||||
@@ -67,8 +71,8 @@ against the SDK's own range download.
|
|||||||
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
||||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||||
| Themes | The shipped near-black palette 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. |
|
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
||||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). |
|
||||||
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
| Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. |
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -103,11 +107,21 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
a local HTTP server: there is no port for another process on the machine to ask
|
a local HTTP server: there is no port for another process on the machine to ask
|
||||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
||||||
would put a share secret in its log and window title.
|
would put a share secret in its log and window title.
|
||||||
- The block cache holds decrypted content. It lives in your cache directory and
|
- The block cache holds decrypted content. It lives in your cache directory,
|
||||||
can be deleted at any time the app is not running.
|
can be deleted at any time the app is not running, and can be sized or
|
||||||
|
emptied from Settings → Storage.
|
||||||
|
- A share link given as a command-line argument is visible to other users of
|
||||||
|
the machine in the process list while that launch runs, and may land in
|
||||||
|
shell history. Pasting it into the window (Ctrl+V) does neither.
|
||||||
|
- The desktop's media widget is handed the title, the episode and a `file://`
|
||||||
|
path to artwork already on disk — never a provider URL, so the shell never
|
||||||
|
contacts a metadata provider on its own.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
application and its combined native distribution are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||||
|
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -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
|
||||||
@@ -7,11 +7,12 @@ distributions.
|
|||||||
| Component | License |
|
| Component | License |
|
||||||
|---|---|
|
|---|---|
|
||||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
|
||||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||||
| Kotlin | Apache-2.0 |
|
| Kotlin | Apache-2.0 |
|
||||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
| UniFFI | MPL-2.0 |
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
| proton-stream shared Rust crates | MIT |
|
| proton-stream shared Rust crates | MIT |
|
||||||
|
|
||||||
This summary is not a replacement for dependency license files. Before a
|
This summary is not a replacement for dependency license files. Before a
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.roborazzi)
|
||||||
}
|
}
|
||||||
|
|
||||||
val repositoryRoot = rootProject.projectDir.parentFile
|
val repositoryRoot = rootProject.projectDir.parentFile
|
||||||
@@ -45,7 +47,7 @@ android {
|
|||||||
namespace = "io.narl.protonstream"
|
namespace = "io.narl.protonstream"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
buildToolsVersion = "37.0.0"
|
buildToolsVersion = "37.0.0"
|
||||||
ndkVersion = "29.0.14206865"
|
ndkVersion = "30.0.16248370"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "io.narl.protonstream"
|
applicationId = "io.narl.protonstream"
|
||||||
@@ -53,6 +55,7 @@ android {
|
|||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = releaseVersionCode
|
versionCode = releaseVersionCode
|
||||||
versionName = releaseVersionName
|
versionName = releaseVersionName
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
@@ -103,7 +106,54 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
|
if (releaseStore != null) signingConfig = signingConfigs.getByName("release")
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
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",
|
||||||
|
// The same for the platform: raising targetSdk opts into new
|
||||||
|
// behaviour and needs its own pass on a device.
|
||||||
|
"OldTargetApi",
|
||||||
|
)
|
||||||
|
sarifReport = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
|||||||
val hostRustLibrary = repositoryRoot.resolve(
|
val hostRustLibrary = repositoryRoot.resolve(
|
||||||
"target/debug/${System.mapLibraryName("pstr_android")}",
|
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||||
)
|
)
|
||||||
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
|
||||||
|
|
||||||
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||||
group = "native"
|
group = "native"
|
||||||
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||||
workingDir(repositoryRoot)
|
workingDir(repositoryRoot)
|
||||||
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||||
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
|
||||||
outputs.files(
|
// deletes the declared outputs of a task it has no history for, which on a
|
||||||
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
// fresh CI checkout threw away the tree just restored from the cache and
|
||||||
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
// rebuilt libmpv from source on every run.
|
||||||
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
|
||||||
stagedMpvDir.map { it.file("REVISION") },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val buildRustHost by tasks.registering(Exec::class) {
|
val buildRustHost by tasks.registering(Exec::class) {
|
||||||
@@ -209,7 +255,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.work.runtime)
|
implementation(libs.androidx.work.runtime)
|
||||||
implementation(libs.jna) { artifact { type = "aar" } }
|
implementation(libs.jna) { artifact { type = "aar" } }
|
||||||
|
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
@@ -219,5 +264,48 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.material3.adaptive)
|
implementation(libs.androidx.compose.material3.adaptive)
|
||||||
implementation(libs.androidx.compose.material3.navigation.suite)
|
implementation(libs.androidx.compose.material3.navigation.suite)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.test.core)
|
||||||
|
testImplementation(libs.androidx.work.testing)
|
||||||
|
testImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.roborazzi)
|
||||||
|
testImplementation(libs.roborazzi.compose)
|
||||||
|
testImplementation(libs.roborazzi.junit.rule)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.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}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screenshot baselines live beside the tests that draw them, so a change to how
|
||||||
|
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
|
||||||
|
// rewrites them; `verifyRoborazziDebug` fails on any difference.
|
||||||
|
roborazzi {
|
||||||
|
outputDir.set(file("src/test/screenshots"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||||
|
// 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** { *; }
|
||||||
@@ -7,3 +7,19 @@
|
|||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
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>;
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
android:name=".ProtonStreamApplication"
|
android:name=".ProtonStreamApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@drawable/ic_launcher_foreground"
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
tools:targetApi="33"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ProtonStream">
|
android:theme="@style/Theme.ProtonStream">
|
||||||
@@ -29,7 +31,8 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
android:label="@string/launcher_name"
|
||||||
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
@@ -38,6 +41,24 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!--
|
||||||
|
Proton Drive public links. autoVerify="false", said explicitly:
|
||||||
|
the domain is Proton's, so this can never be a verified App Link.
|
||||||
|
Android only opens these here once "Open supported links" is
|
||||||
|
allowed in the app's settings; sharing a link to the app works
|
||||||
|
without that.
|
||||||
|
-->
|
||||||
|
<intent-filter android:autoVerify="false">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="drive.proton.me" android:pathPrefix="/urls/" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -6,11 +6,12 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
|||||||
| Component | License |
|
| Component | License |
|
||||||
|---|---|
|
|---|---|
|
||||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
|
||||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||||
| Kotlin | Apache-2.0 |
|
| Kotlin | Apache-2.0 |
|
||||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
| UniFFI | MPL-2.0 |
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
| proton-stream reusable Rust crates | MIT |
|
| proton-stream reusable Rust crates | MIT |
|
||||||
|
|
||||||
Before release, replace this dependency summary with an inventory generated
|
Before release, replace this dependency summary with an inventory generated
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ add_library(pstr_mpv SHARED pstr_mpv.cpp)
|
|||||||
add_library(mpv SHARED IMPORTED)
|
add_library(mpv SHARED IMPORTED)
|
||||||
set_target_properties(mpv PROPERTIES
|
set_target_properties(mpv PROPERTIES
|
||||||
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
|
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_features(pstr_mpv PRIVATE cxx_std_20)
|
||||||
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
|
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
|
||||||
@@ -20,4 +26,4 @@ target_include_directories(pstr_mpv PRIVATE "${PSTR_MPV_ROOT}/include")
|
|||||||
# packaged into the same ABI directory in the APK.
|
# packaged into the same ABI directory in the APK.
|
||||||
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
|
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_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
|
||||||
target_link_libraries(pstr_mpv PRIVATE mpv android EGL log)
|
target_link_libraries(pstr_mpv PRIVATE mpv avcodec android EGL log)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "pstr_mpv.h"
|
#include "pstr_mpv.h"
|
||||||
|
|
||||||
#include <EGL/egl.h>
|
#include <EGL/egl.h>
|
||||||
|
#include <android/log.h>
|
||||||
#include <android/native_window.h>
|
#include <android/native_window.h>
|
||||||
#include <android/native_window_jni.h>
|
#include <android/native_window_jni.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
@@ -8,6 +9,10 @@
|
|||||||
#include <mpv/render_gl.h>
|
#include <mpv/render_gl.h>
|
||||||
#include <mpv/stream_cb.h>
|
#include <mpv/stream_cb.h>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavcodec/jni.h>
|
||||||
|
}
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
@@ -26,6 +31,8 @@
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char *kProtocol = "pstr";
|
constexpr const char *kProtocol = "pstr";
|
||||||
|
/// mpv's `hwdec` when hardware decoding is on. Why this value: `Player()`.
|
||||||
|
constexpr const char *kHardwareDecoding = "auto-copy-safe";
|
||||||
|
|
||||||
struct StreamCookie {
|
struct StreamCookie {
|
||||||
uint64_t handle;
|
uint64_t handle;
|
||||||
@@ -86,16 +93,32 @@ int64_t stream_size(void *opaque) noexcept {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
void stream_cancel(void *opaque) noexcept {
|
||||||
c_boundary([=] {
|
c_boundary([=] {
|
||||||
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
if (auto *stream = static_cast<StreamCookie *>(opaque)) {
|
||||||
stream->cancelled.store(true, std::memory_order_relaxed);
|
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 {
|
void stream_close(void *opaque) noexcept {
|
||||||
c_boundary([=] { delete static_cast<StreamCookie *>(opaque); });
|
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 {
|
int stream_open(void *, char *uri, mpv_stream_cb_info *info) noexcept {
|
||||||
@@ -130,6 +153,29 @@ void *resolve_gl(void *, const char *name) noexcept {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
struct PlaybackState {
|
||||||
double position = 0.0;
|
double position = 0.0;
|
||||||
double duration = 0.0;
|
double duration = 0.0;
|
||||||
@@ -137,6 +183,33 @@ struct PlaybackState {
|
|||||||
bool paused = true;
|
bool paused = true;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
bool ended = 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;
|
||||||
|
/// How far into the file the demuxer has read ahead, in seconds; what the
|
||||||
|
/// seek bar shades, so a jump into it is known to cost no fetch.
|
||||||
|
double cached_until = 0.0;
|
||||||
|
/// True between `loadfile` being issued and mpv acknowledging it with
|
||||||
|
/// `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 {
|
class Player {
|
||||||
@@ -148,7 +221,16 @@ class Player {
|
|||||||
option("vo", "libmpv");
|
option("vo", "libmpv");
|
||||||
option("force-window", "no");
|
option("force-window", "no");
|
||||||
option("video-timing-offset", "0");
|
option("video-timing-offset", "0");
|
||||||
option("hwdec", "auto-safe");
|
// The default; `load` sets it per file from the viewer's setting.
|
||||||
|
// `-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", kHardwareDecoding);
|
||||||
option("cache", "yes");
|
option("cache", "yes");
|
||||||
option("cache-secs", "30");
|
option("cache-secs", "30");
|
||||||
option("demuxer-readahead-secs", "30");
|
option("demuxer-readahead-secs", "30");
|
||||||
@@ -165,6 +247,14 @@ class Player {
|
|||||||
observe("pause", 3, MPV_FORMAT_FLAG);
|
observe("pause", 3, MPV_FORMAT_FLAG);
|
||||||
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
observe("volume", 4, MPV_FORMAT_DOUBLE);
|
||||||
observe("mute", 5, MPV_FORMAT_FLAG);
|
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);
|
||||||
|
observe("demuxer-cache-time", 10, MPV_FORMAT_DOUBLE);
|
||||||
|
// 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(); });
|
events_ = std::thread([this] { event_loop(); });
|
||||||
renderer_ = std::thread([this] { render_loop(); });
|
renderer_ = std::thread([this] { render_loop(); });
|
||||||
}
|
}
|
||||||
@@ -211,25 +301,66 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool load(uint64_t handle, double start, const std::string &audio,
|
bool load(uint64_t handle, double start, const std::string &audio,
|
||||||
const std::string &subtitle, bool subtitles) {
|
const std::string &subtitle, bool subtitles,
|
||||||
|
bool hardware_decoding) {
|
||||||
if (!mpv_) return false;
|
if (!mpv_) return false;
|
||||||
{
|
// Per file, before `loadfile`: a device whose decoder shows green
|
||||||
std::unique_lock lock(render_mutex_);
|
// frames is fixed from the next episode without restarting the core.
|
||||||
if (!render_cv_.wait_for(lock, std::chrono::seconds(5), [this] { return render_ != nullptr || !running_.load(); })) return false;
|
set_string("hwdec", hardware_decoding ? kHardwareDecoding : "no");
|
||||||
}
|
// 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 (!audio.empty()) set_string("alang", audio);
|
||||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||||
set_string("sid", subtitles ? "auto" : "no");
|
set_string("sid", subtitles ? "auto" : "no");
|
||||||
pending_start_.store(std::max(0.0, start));
|
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 std::string url = "pstr://" + std::to_string(handle);
|
||||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||||
if (mpv_command(mpv_, command) < 0) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void pause(bool value) { set_flag("pause", value); }
|
/// 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 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 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 mute(bool value) { set_flag("mute", value); }
|
||||||
void select_track(bool audio, int64_t id) {
|
void select_track(bool audio, int64_t id) {
|
||||||
const char *property = audio ? "aid" : "sid";
|
const char *property = audio ? "aid" : "sid";
|
||||||
@@ -246,6 +377,12 @@ class Player {
|
|||||||
return state_;
|
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 {
|
std::string tracks_json() const {
|
||||||
if (!mpv_) return "[]";
|
if (!mpv_) return "[]";
|
||||||
int64_t count = 0;
|
int64_t count = 0;
|
||||||
@@ -274,22 +411,115 @@ class Player {
|
|||||||
return json + ']';
|
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:
|
private:
|
||||||
struct MpvFree {
|
struct MpvFree {
|
||||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||||
};
|
};
|
||||||
using MpvString = std::unique_ptr<char, MpvFree>;
|
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) {
|
static std::string escape(const char *text) {
|
||||||
std::string out;
|
std::string out;
|
||||||
if (!text) return out;
|
if (!text) return out;
|
||||||
for (const unsigned char c : std::string_view(text)) {
|
const std::string_view input(text);
|
||||||
if (c == '"' || c == '\\') out += '\\';
|
for (size_t i = 0; i < input.size();) {
|
||||||
if (c >= 0x20) out += static_cast<char>(c);
|
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;
|
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 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 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_string(const char *name, const std::string &value) { mpv_set_property_string(mpv_, name, value.c_str()); }
|
||||||
@@ -306,25 +536,73 @@ class Player {
|
|||||||
if (start > 0.0) set_double("time-pos", start);
|
if (start > 0.0) set_double("time-pos", start);
|
||||||
}
|
}
|
||||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||||
|
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||||
std::lock_guard lock(state_mutex_);
|
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_.ended = true;
|
||||||
|
state_.end_reason = end ? end_reason_of(end->reason) : EndReason::Other;
|
||||||
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
} else if (event->event_id == MPV_EVENT_START_FILE) {
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
|
state_.loading = false;
|
||||||
state_.ended = 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) {
|
} else if (event->event_id == MPV_EVENT_PROPERTY_CHANGE) {
|
||||||
update_property(*static_cast<mpv_event_property *>(event->data));
|
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) {
|
void update_property(const mpv_event_property &property) {
|
||||||
if (!property.data) return;
|
if (!property.data) return;
|
||||||
std::lock_guard lock(state_mutex_);
|
std::lock_guard lock(state_mutex_);
|
||||||
if (!std::strcmp(property.name, "time-pos")) state_.position = *static_cast<double *>(property.data);
|
// `volume`, `mute` and `pause` belong to the core and carry across a
|
||||||
else if (!std::strcmp(property.name, "duration")) state_.duration = *static_cast<double *>(property.data);
|
// load; the rest describe the open file, and a reading of them that
|
||||||
else if (!std::strcmp(property.name, "volume")) state_.volume = *static_cast<double *>(property.data);
|
// 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, "pause")) state_.paused = *static_cast<int *>(property.data);
|
||||||
else if (!std::strcmp(property.name, "mute")) state_.muted = *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));
|
||||||
|
else if (!std::strcmp(property.name, "demuxer-cache-time")) state_.cached_until = *static_cast<double *>(property.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void render_update(void *opaque) noexcept {
|
static void render_update(void *opaque) noexcept {
|
||||||
@@ -336,6 +614,15 @@ class Player {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
void render_loop() {
|
||||||
EGLDisplay display = EGL_NO_DISPLAY;
|
EGLDisplay display = EGL_NO_DISPLAY;
|
||||||
EGLContext context = EGL_NO_CONTEXT;
|
EGLContext context = EGL_NO_CONTEXT;
|
||||||
@@ -343,6 +630,7 @@ class Player {
|
|||||||
EGLSurface pbuffer = EGL_NO_SURFACE;
|
EGLSurface pbuffer = EGL_NO_SURFACE;
|
||||||
EGLConfig config = nullptr;
|
EGLConfig config = nullptr;
|
||||||
ANativeWindow *window = nullptr;
|
ANativeWindow *window = nullptr;
|
||||||
|
if (initialize_egl(display, context, config, pbuffer)) initialize_renderer();
|
||||||
while (running_.load()) {
|
while (running_.load()) {
|
||||||
std::unique_lock lock(render_mutex_);
|
std::unique_lock lock(render_mutex_);
|
||||||
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
render_cv_.wait(lock, [this] { return !running_.load() || surface_changed_ || frame_ready_.load(); });
|
||||||
@@ -353,18 +641,31 @@ class Player {
|
|||||||
if (window) ANativeWindow_release(window);
|
if (window) ANativeWindow_release(window);
|
||||||
window = pending_window_;
|
window = pending_window_;
|
||||||
pending_window_ = nullptr;
|
pending_window_ = nullptr;
|
||||||
if (window && display == EGL_NO_DISPLAY && !initialize_egl(display, context, config, pbuffer)) {
|
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);
|
ANativeWindow_release(window);
|
||||||
window = nullptr;
|
window = nullptr;
|
||||||
}
|
}
|
||||||
if (window) {
|
if (window) {
|
||||||
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
surface = eglCreateWindowSurface(display, config, window, nullptr);
|
||||||
eglMakeCurrent(display, surface, surface, context);
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
initialize_renderer();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bool draw = frame_ready_.exchange(false);
|
const bool draw = frame_ready_.exchange(false);
|
||||||
lock.unlock();
|
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_) {
|
if (draw && surface != EGL_NO_SURFACE && render_) {
|
||||||
eglMakeCurrent(display, surface, surface, context);
|
eglMakeCurrent(display, surface, surface, context);
|
||||||
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
if (mpv_render_context_update(render_) & MPV_RENDER_UPDATE_FRAME) {
|
||||||
@@ -394,18 +695,40 @@ class Player {
|
|||||||
if (pending_window_) { ANativeWindow_release(pending_window_); pending_window_ = nullptr; }
|
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) {
|
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);
|
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||||
if (display == EGL_NO_DISPLAY || !eglInitialize(display, nullptr, nullptr)) return false;
|
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};
|
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;
|
EGLint count = 0;
|
||||||
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return false;
|
if (!eglChooseConfig(display, attributes, &config, 1, &count) || count == 0) return fail("eglChooseConfig");
|
||||||
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
const EGLint context_attributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
|
||||||
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attributes);
|
||||||
if (context == EGL_NO_CONTEXT) return false;
|
if (context == EGL_NO_CONTEXT) return fail("eglCreateContext");
|
||||||
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
const EGLint pbuffer_attributes[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE};
|
||||||
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
pbuffer = eglCreatePbufferSurface(display, config, pbuffer_attributes);
|
||||||
return pbuffer != EGL_NO_SURFACE && eglMakeCurrent(display, pbuffer, pbuffer, context);
|
if (pbuffer == EGL_NO_SURFACE) return fail("eglCreatePbufferSurface");
|
||||||
|
if (!eglMakeCurrent(display, pbuffer, pbuffer, context)) return fail("eglMakeCurrent");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void initialize_renderer() {
|
void initialize_renderer() {
|
||||||
@@ -426,6 +749,8 @@ class Player {
|
|||||||
std::thread renderer_;
|
std::thread renderer_;
|
||||||
mutable std::mutex state_mutex_;
|
mutable std::mutex state_mutex_;
|
||||||
PlaybackState state_;
|
PlaybackState state_;
|
||||||
|
/// The first error mpv logged about the open file, until it is consumed.
|
||||||
|
std::string error_;
|
||||||
std::mutex render_mutex_;
|
std::mutex render_mutex_;
|
||||||
std::condition_variable render_cv_;
|
std::condition_variable render_cv_;
|
||||||
ANativeWindow *pending_window_ = nullptr;
|
ANativeWindow *pending_window_ = nullptr;
|
||||||
@@ -465,13 +790,33 @@ bool utf8(JNIEnv *env, jstring value, std::string &result) {
|
|||||||
void reject_null_surface(JNIEnv *env) noexcept {
|
void reject_null_surface(JNIEnv *env) noexcept {
|
||||||
c_boundary([=] {
|
c_boundary([=] {
|
||||||
jclass type = env->FindClass("java/lang/IllegalArgumentException");
|
jclass type = env->FindClass("java/lang/IllegalArgumentException");
|
||||||
if (type) env->ThrowNew(type, "surface must not be null");
|
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
|
} // namespace
|
||||||
|
|
||||||
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jobject) noexcept {
|
/// 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, [] {
|
return c_boundary<jlong>(0, [] {
|
||||||
auto player = std::make_unique<Player>();
|
auto player = std::make_unique<Player>();
|
||||||
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
|
return player->valid() ? reinterpret_cast<jlong>(player.release()) : 0;
|
||||||
@@ -489,7 +834,7 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
|
|||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles) noexcept {
|
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles, jboolean hardware_decoding) noexcept {
|
||||||
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
||||||
std::string audio_text;
|
std::string audio_text;
|
||||||
std::string subtitle_text;
|
std::string subtitle_text;
|
||||||
@@ -499,7 +844,8 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
|||||||
auto *p = from(handle);
|
auto *p = from(handle);
|
||||||
return static_cast<jboolean>(
|
return static_cast<jboolean>(
|
||||||
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
||||||
subtitle_text, subtitles == JNI_TRUE)
|
subtitle_text, subtitles == JNI_TRUE,
|
||||||
|
hardware_decoding == JNI_TRUE)
|
||||||
? JNI_TRUE
|
? JNI_TRUE
|
||||||
: JNI_FALSE);
|
: JNI_FALSE);
|
||||||
});
|
});
|
||||||
@@ -513,6 +859,9 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
|
|||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeVolume(JNIEnv *, jobject, jlong h, jdouble value) noexcept {
|
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); });
|
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 {
|
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); });
|
c_boundary([=] { if (auto *p = from(h)) p->mute(value == JNI_TRUE); });
|
||||||
}
|
}
|
||||||
@@ -527,19 +876,38 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
|||||||
const auto state = from(h) ? from(h)->state() : PlaybackState{};
|
const auto state = from(h) ? from(h)->state() : PlaybackState{};
|
||||||
const jdouble values[] = {state.position, state.duration, state.volume,
|
const jdouble values[] = {state.position, state.duration, state.volume,
|
||||||
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
state.paused ? 1.0 : 0.0, state.muted ? 1.0 : 0.0,
|
||||||
state.ended ? 1.0 : 0.0};
|
state.ended ? 1.0 : 0.0,
|
||||||
jdoubleArray result = env->NewDoubleArray(6);
|
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, state.cached_until};
|
||||||
|
constexpr jsize kFields = 14;
|
||||||
|
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
env->SetDoubleArrayRegion(result, 0, 6, values);
|
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||||
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
if (env->ExceptionCheck()) return static_cast<jdoubleArray>(nullptr);
|
||||||
return result;
|
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 {
|
extern "C" JNIEXPORT jstring JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeTracks(JNIEnv *env, jobject, jlong h) noexcept {
|
||||||
return c_boundary<jstring>(nullptr, [=] {
|
return c_boundary<jstring>(nullptr, [=] {
|
||||||
return string(env, from(h) ? from(h)->tracks_json() : "[]");
|
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 {
|
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)); });
|
c_boundary([=] { pstr_android_stream_release(static_cast<uint64_t>(handle)); });
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,9 @@ extern "C" {
|
|||||||
#endif
|
#endif
|
||||||
int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length);
|
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);
|
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);
|
void pstr_android_stream_release(uint64_t handle);
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Proton Drive public link, wherever in a piece of text it sits.
|
||||||
|
*
|
||||||
|
* The fragment is kept: it is the share's secret, and a link without it cannot
|
||||||
|
* be opened. Matching stops at whitespace and at the quotes and brackets a
|
||||||
|
* message app wraps a link in, which never occur in a link itself.
|
||||||
|
*/
|
||||||
|
private val SHARE_LINK = Regex("""https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+""")
|
||||||
|
|
||||||
|
/** The share link an `ACTION_VIEW` URL or an `ACTION_SEND` text carries, if any. */
|
||||||
|
internal fun shareLinkFrom(action: String?, url: String?, text: CharSequence?): String? = when (action) {
|
||||||
|
Intent.ACTION_VIEW -> url?.let { SHARE_LINK.matchEntire(it)?.value }
|
||||||
|
Intent.ACTION_SEND -> text?.let { SHARE_LINK.find(it)?.value }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Intent.shareLink(): String? =
|
||||||
|
shareLinkFrom(action, dataString, getCharSequenceExtra(Intent.EXTRA_TEXT))
|
||||||
@@ -1,72 +1,207 @@
|
|||||||
package io.narl.protonstream
|
package io.narl.protonstream
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.app.PendingIntent
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Build
|
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
|
import android.app.RemoteAction
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.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.os.IBinder
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import io.narl.protonstream.ui.ProtonStreamApp
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
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.NativeMpvHost
|
||||||
import io.narl.protonstream.playback.PlaybackService
|
import io.narl.protonstream.playback.PlaybackService
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
|
private val inPictureInPicture = mutableStateOf(false)
|
||||||
|
|
||||||
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||||
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||||
|
/**
|
||||||
|
* 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 var bound = false
|
||||||
private val playbackConnection = object : ServiceConnection {
|
private val playbackConnection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||||
bound = true
|
|
||||||
}
|
}
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
playerHost.value = null
|
playerHost.value = null
|
||||||
bound = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transport behind the buttons Android draws over the PiP window.
|
||||||
|
*
|
||||||
|
* A broadcast rather than a service intent: PiP is one of the states in
|
||||||
|
* which a background service start is refused, and the receiver is
|
||||||
|
* registered for as long as the activity that owns the window exists.
|
||||||
|
*/
|
||||||
|
private val pictureInPictureControls = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val host = playerHost.value ?: return
|
||||||
|
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
||||||
|
CONTROL_PLAY -> host.setPaused(false)
|
||||||
|
CONTROL_PAUSE -> host.setPaused(true)
|
||||||
|
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
||||||
|
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val notificationPermission = registerForActivityResult(
|
private val notificationPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// Before anything draws. The palette decides the window's own
|
||||||
|
// 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)
|
||||||
|
// Only on a fresh start: a recreated activity is handed the intent it
|
||||||
|
// was launched with again, and would offer to add the same share twice.
|
||||||
|
if (savedInstanceState == null) incomingShareLink.value = intent.shareLink()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE)
|
// 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 (
|
if (
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
|
// Auto-enter has to be armed *before* the gesture that would use it:
|
||||||
|
// building the params inside onUserLeaveHint is too late for the swipe
|
||||||
|
// home that gesture navigation sends, which never calls it. Re-register
|
||||||
|
// whenever the picture's shape or the transport's does.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
snapshotFlow { playerHost.value }
|
||||||
|
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
||||||
|
.map { state -> PictureInPictureShape.of(state) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(playerHost.value)
|
ProtonStreamApp(
|
||||||
|
playerHost.value,
|
||||||
|
inPictureInPicture.value,
|
||||||
|
shareLink = incomingShareLink.value,
|
||||||
|
onShareLinkTaken = { incomingShareLink.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
/**
|
||||||
super.onUserLeaveHint()
|
* The parts of the window Compose does not reach: the background behind the
|
||||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
* whole activity, and the polarity of the system bars' icons.
|
||||||
enterPictureInPictureMode(
|
*
|
||||||
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
|
* 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// singleTask: a link opened while the app is running arrives here, not in
|
||||||
|
// a second activity.
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
// The backstop for launchers that still send this instead of arming
|
||||||
|
// auto-enter. Entering twice is not an error; not entering at all is.
|
||||||
|
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
||||||
|
runCatching {
|
||||||
|
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||||
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
|
// The window is now a thumbnail with its own controls. Anything the
|
||||||
|
// player would draw over it — the transport, the title bar, the skip
|
||||||
|
// offer — is unreadable at that size and covers the picture.
|
||||||
|
inPictureInPicture.value = isInPictureInPictureMode
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
if (bound) unbindService(playbackConnection)
|
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||||
|
if (bound) {
|
||||||
|
unbindService(playbackConnection)
|
||||||
|
bound = false
|
||||||
|
}
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,4 +215,81 @@ class MainActivity : ComponentActivity() {
|
|||||||
playerHost.value?.stop()
|
playerHost.value?.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
||||||
|
private data class PictureInPictureShape(
|
||||||
|
val active: Boolean,
|
||||||
|
val paused: Boolean,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
||||||
|
active = state != null && state.duration > 0.0 && !state.ended,
|
||||||
|
paused = state?.paused != false,
|
||||||
|
width = state?.videoWidth?.toInt() ?: 0,
|
||||||
|
height = state?.videoHeight?.toInt() ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
||||||
|
PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(aspectRatio(width, height))
|
||||||
|
.setAutoEnterEnabled(active)
|
||||||
|
.setSeamlessResizeEnabled(false)
|
||||||
|
// The player owns the whole window, so the picture's rectangle is
|
||||||
|
// the window's. Without it the transition into PiP cross-fades from
|
||||||
|
// a screenshot instead of shrinking the video that is already there.
|
||||||
|
.setSourceRectHint(windowBounds())
|
||||||
|
.setActions(
|
||||||
|
listOf(
|
||||||
|
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
||||||
|
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
||||||
|
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
||||||
|
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
||||||
|
|
||||||
|
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(this, icon),
|
||||||
|
label,
|
||||||
|
label,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
this,
|
||||||
|
requestCode,
|
||||||
|
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
||||||
|
const val EXTRA_CONTROL = "control"
|
||||||
|
const val CONTROL_PLAY = "play"
|
||||||
|
const val CONTROL_PAUSE = "pause"
|
||||||
|
const val CONTROL_NEXT = "next"
|
||||||
|
const val CONTROL_PREVIOUS = "previous"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
||||||
|
* and refuses the request outright rather than clamping it — so a
|
||||||
|
* 2.40:1 scope release would take PiP away entirely.
|
||||||
|
*/
|
||||||
|
fun aspectRatio(width: Int, height: Int): Rational {
|
||||||
|
if (width <= 0 || height <= 0) return Rational(16, 9)
|
||||||
|
val ratio = width.toDouble() / height
|
||||||
|
return when {
|
||||||
|
ratio > MAX_RATIO -> Rational(239, 100)
|
||||||
|
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
||||||
|
else -> Rational(width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const val MAX_RATIO = 2.39
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,151 @@
|
|||||||
package io.narl.protonstream.download
|
package io.narl.protonstream.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.StatFs
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
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
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
object DownloadCoordinator {
|
object DownloadCoordinator {
|
||||||
fun enqueue(context: Context, episode: EpisodeRecord) {
|
/**
|
||||||
enqueue(
|
* Where bulk queueing runs.
|
||||||
context,
|
*
|
||||||
RetainedDownload(episode.shareId, episode.volumeId, episode.linkId, episode.label),
|
* Callers are click handlers, and "Download show" on a long series is a
|
||||||
ExistingWorkPolicy.KEEP,
|
* 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) {
|
fun resume(context: Context, download: RetainedDownload) {
|
||||||
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
enqueue(context, download, ExistingWorkPolicy.REPLACE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
private fun enqueue(context: Context, download: RetainedDownload, policy: ExistingWorkPolicy) {
|
||||||
DownloadStateStore(context).put(
|
val queued = download.copy(status = RetainedDownload.STATUS_QUEUED, error = null)
|
||||||
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),
|
||||||
)
|
)
|
||||||
val request = OneTimeWorkRequestBuilder<OfflineDownloadWorker>()
|
}
|
||||||
|
|
||||||
|
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(
|
.setConstraints(
|
||||||
Constraints.Builder().setRequiredNetworkType(
|
Constraints.Builder()
|
||||||
requiredNetworkType(SettingsStore(context).wifiOnly),
|
.setRequiredNetworkType(requiredNetworkType(wifiOnly))
|
||||||
).build(),
|
// 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(
|
.setInputData(
|
||||||
workDataOf(
|
workDataOf(
|
||||||
@@ -45,26 +159,6 @@ object DownloadCoordinator {
|
|||||||
.addTag("$EPISODE_TAG${download.label}")
|
.addTag("$EPISODE_TAG${download.label}")
|
||||||
.addTag(shareTag(download.shareId))
|
.addTag(shareTag(download.shareId))
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
||||||
workName(download.shareId, download.linkId),
|
|
||||||
policy,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueue(context: Context, episodes: Iterable<EpisodeRecord>) {
|
|
||||||
episodes.filterNot(EpisodeRecord::offline).forEach { enqueue(context, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause(context: Context, download: RetainedDownload) {
|
|
||||||
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_PAUSED, error = null))
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel(context: Context, download: RetainedDownload) {
|
|
||||||
DownloadStateStore(context).put(download.copy(status = RetainedDownload.STATUS_CANCELLED))
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(workName(download.shareId, download.linkId))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
|
fun shareTag(shareId: String) = "$SHARE_TAG$shareId"
|
||||||
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
fun workName(shareId: String, linkId: String) = "offline-$shareId-$linkId"
|
||||||
@@ -72,6 +166,9 @@ object DownloadCoordinator {
|
|||||||
const val TAG = "offline-download"
|
const val TAG = "offline-download"
|
||||||
const val EPISODE_TAG = "offline-episode:"
|
const val EPISODE_TAG = "offline-episode:"
|
||||||
private const val SHARE_TAG = "offline-share:"
|
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 =
|
internal fun requiredNetworkType(wifiOnly: Boolean): NetworkType =
|
||||||
|
|||||||
@@ -35,18 +35,59 @@ class DownloadStateStore(context: Context) {
|
|||||||
fun get(shareId: String, linkId: String): RetainedDownload? =
|
fun get(shareId: String, linkId: String): RetainedDownload? =
|
||||||
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
preferences.getString(key(shareId, linkId), null)?.let { runCatching { decode(it) }.getOrNull() }
|
||||||
|
|
||||||
fun put(record: RetainedDownload) {
|
fun put(record: RetainedDownload) = synchronized(WRITES) {
|
||||||
preferences.edit().putString(record.key, encode(record)).commit()
|
edit { putString(record.key, encode(record)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(shareId: String, linkId: String) {
|
/**
|
||||||
preferences.edit().remove(key(shareId, linkId)).commit()
|
* 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 removeShare(shareId: String) {
|
fun remove(shareId: String, linkId: String) = synchronized(WRITES) {
|
||||||
val edit = preferences.edit()
|
edit { remove(key(shareId, linkId)) }
|
||||||
records().filter { it.shareId == shareId }.forEach { edit.remove(it.key) }
|
}
|
||||||
edit.commit()
|
|
||||||
|
/** 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()
|
private fun encode(record: RetainedDownload) = JSONObject()
|
||||||
@@ -78,5 +119,11 @@ class DownloadStateStore(context: Context) {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val NAME = "offline_download_state"
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
@@ -23,6 +24,7 @@ class OfflineDownloadWorker(
|
|||||||
parameters: WorkerParameters,
|
parameters: WorkerParameters,
|
||||||
) : CoroutineWorker(appContext, parameters) {
|
) : CoroutineWorker(appContext, parameters) {
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
createNotificationChannel()
|
||||||
setForeground(createForegroundInfo(0))
|
setForeground(createForegroundInfo(0))
|
||||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||||
@@ -35,20 +37,43 @@ class OfflineDownloadWorker(
|
|||||||
store.put(retained)
|
store.put(retained)
|
||||||
|
|
||||||
val observer = object : DownloadObserver {
|
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) {
|
override fun onProgress(downloaded: ULong, total: ULong) {
|
||||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val 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()))
|
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||||
setForegroundAsync(createForegroundInfo(percent))
|
setForegroundAsync(createForegroundInfo(percent))
|
||||||
val requested = store.get(shareId, linkId)
|
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||||
val requestedStatus = requested?.status
|
// this read-modify-write used to put RUNNING back over the
|
||||||
retained = (requested ?: retained).copy(
|
// viewer's PAUSED and then report the download as cancelled.
|
||||||
downloaded = downloaded.toLong(),
|
store.update(shareId, linkId) { stored ->
|
||||||
total = total.toLong(),
|
val current = stored ?: retained
|
||||||
status = if (requestedStatus == RetainedDownload.STATUS_PAUSED ||
|
current.copy(
|
||||||
requestedStatus == RetainedDownload.STATUS_CANCELLED
|
downloaded = downloaded.toLong(),
|
||||||
) requestedStatus else RetainedDownload.STATUS_RUNNING,
|
total = total.toLong(),
|
||||||
)
|
status = when (current.status) {
|
||||||
store.put(retained)
|
RetainedDownload.STATUS_PAUSED,
|
||||||
|
RetainedDownload.STATUS_CANCELLED,
|
||||||
|
-> current.status
|
||||||
|
else -> RetainedDownload.STATUS_RUNNING
|
||||||
|
},
|
||||||
|
).also { retained = it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isCancelled(): Boolean = isStopped
|
override fun isCancelled(): Boolean = isStopped
|
||||||
@@ -62,9 +87,12 @@ class OfflineDownloadWorker(
|
|||||||
},
|
},
|
||||||
onFailure = { failure ->
|
onFailure = { failure ->
|
||||||
if (isStopped) {
|
if (isStopped) {
|
||||||
val current = store.get(shareId, linkId) ?: retained
|
// A pause is also a stop. Under the lock, so the record read
|
||||||
if (current.status != RetainedDownload.STATUS_PAUSED) {
|
// here is the one the write lands on.
|
||||||
store.put(current.copy(status = RetainedDownload.STATUS_CANCELLED))
|
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"))
|
Result.failure(error("cancelled"))
|
||||||
} else if (runAttemptCount < MAX_RETRIES) {
|
} else if (runAttemptCount < MAX_RETRIES) {
|
||||||
@@ -79,11 +107,14 @@ class OfflineDownloadWorker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
/** Once per run, rather than once per 4 MiB block. */
|
||||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
private fun createNotificationChannel() {
|
||||||
manager.createNotificationChannel(
|
applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
NotificationChannel(CHANNEL_ID, "Offline downloads", NotificationManager.IMPORTANCE_LOW),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||||
.setContentTitle("Making episode available offline")
|
.setContentTitle("Making episode available offline")
|
||||||
@@ -109,6 +140,9 @@ class OfflineDownloadWorker(
|
|||||||
const val KEY_TOTAL = "total"
|
const val KEY_TOTAL = "total"
|
||||||
private const val MAX_RETRIES = 3
|
private const val MAX_RETRIES = 3
|
||||||
private const val CHANNEL_ID = "offline-downloads"
|
private const val CHANNEL_ID = "offline-downloads"
|
||||||
|
|
||||||
|
/** Shortest gap between two identical-looking progress reports. */
|
||||||
|
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.native
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
|
|||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
import uniffi.pstr_android.AndroidSecretStore
|
import uniffi.pstr_android.AndroidSecretStore
|
||||||
|
import uniffi.pstr_android.BridgeException
|
||||||
|
|
||||||
/** Encrypts share fragments and passwords before they reach app preferences. */
|
/**
|
||||||
|
* 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 {
|
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
override fun set(key: String, value: String) {
|
override fun set(key: String, value: String) = bridging("store credential") {
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val payload = try {
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
seal(value)
|
||||||
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
} catch (invalidated: KeyPermanentlyInvalidatedException) {
|
||||||
val payload = cipher.iv + encrypted
|
// 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())
|
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun get(key: String): String? {
|
private fun seal(value: String): ByteArray {
|
||||||
val encoded = preferences.getString(key, null) ?: return null
|
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)
|
val payload = Base64.decode(encoded, Base64.NO_WRAP)
|
||||||
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
|||||||
encryptionKey(),
|
encryptionKey(),
|
||||||
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
||||||
)
|
)
|
||||||
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(key: String) {
|
override fun delete(key: String) = bridging("delete credential") {
|
||||||
check(preferences.edit().remove(key).commit())
|
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 {
|
private fun encryptionKey(): SecretKey {
|
||||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
package io.narl.protonstream.native
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import uniffi.pstr_android.AndroidEngine
|
import uniffi.pstr_android.AndroidEngine
|
||||||
import uniffi.pstr_android.AndroidPaths
|
import uniffi.pstr_android.AndroidPaths
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
|
||||||
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
||||||
object NativeRuntime {
|
object NativeRuntime {
|
||||||
private lateinit var appContext: Context
|
private lateinit var appContext: Context
|
||||||
@Volatile private var instance: AndroidEngine? = null
|
@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) {
|
fun initialize(context: Context) {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
initTls(context)
|
tlsReady = initTls(context)
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initializes Android's certificate verifier before Proton networking. */
|
/** Initializes Android's certificate verifier before Proton networking. */
|
||||||
private external fun initTls(context: Context)
|
private external fun initTls(context: Context): Boolean
|
||||||
|
|
||||||
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
||||||
instance ?: synchronized(this@NativeRuntime) {
|
instance ?: synchronized(this@NativeRuntime) {
|
||||||
@@ -27,14 +38,28 @@ object NativeRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createEngine(): AndroidEngine {
|
/**
|
||||||
|
* 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" }
|
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
|
||||||
val config = File(appContext.noBackupFilesDir, "config")
|
val config = File(appContext.noBackupFilesDir, "config")
|
||||||
val data = File(appContext.filesDir, "data")
|
val data = File(appContext.filesDir, "data")
|
||||||
val cache = File(appContext.cacheDir, "stream")
|
val cache = File(appContext.cacheDir, "stream")
|
||||||
return AndroidEngine(
|
return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
|
||||||
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
|
|
||||||
KeystoreSecretStore(appContext),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createEngine(): AndroidEngine =
|
||||||
|
AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply {
|
||||||
|
// The budget is Android's setting, so the engine starts on the
|
||||||
|
// default and is told the chosen one before anything streams.
|
||||||
|
setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
package io.narl.protonstream.playback
|
|
||||||
|
|
||||||
import android.view.Surface
|
|
||||||
import android.view.SurfaceHolder
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.Pause
|
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
|
||||||
import androidx.compose.material.icons.filled.FullscreenExit
|
|
||||||
import androidx.compose.material.icons.filled.VolumeUp
|
|
||||||
import androidx.compose.material.icons.filled.VolumeOff
|
|
||||||
import androidx.compose.material.icons.filled.Subtitles
|
|
||||||
import androidx.compose.material.icons.filled.Audiotrack
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import uniffi.pstr_android.AndroidEngine
|
|
||||||
import uniffi.pstr_android.AndroidStream
|
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun closeEngine() = withContext(Dispatchers.IO) {
|
|
||||||
engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
|
|
||||||
engine = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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}. */
|
|
||||||
suspend fun play(
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class PlaybackStartup(
|
|
||||||
val nativeHandle: ULong,
|
|
||||||
val size: ULong,
|
|
||||||
val startPosition: Double,
|
|
||||||
val audioLanguage: String?,
|
|
||||||
val subtitleLanguage: String?,
|
|
||||||
val subtitles: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LibmpvPlayerSurface(
|
|
||||||
titleKey: String,
|
|
||||||
episode: EpisodeRecord,
|
|
||||||
host: LibmpvHost? = null,
|
|
||||||
isFullscreen: Boolean = false,
|
|
||||||
onToggleFullscreen: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
if (host == null) {
|
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var playbackError by remember(sessionKey(episode)) { mutableStateOf<String?>(null) }
|
|
||||||
val session = remember(episode.shareId, episode.linkId) {
|
|
||||||
RustStreamSession(episode)
|
|
||||||
}
|
|
||||||
LaunchedEffect(session, host) {
|
|
||||||
runCatching {
|
|
||||||
val startup = withContext(Dispatchers.IO) {
|
|
||||||
session.open()
|
|
||||||
val engine = NativeRuntime.engine()
|
|
||||||
val preferences = engine.titleTrackPreferences(titleKey)
|
|
||||||
val watch = engine.watchState(episode.shareId, episode.linkId)
|
|
||||||
PlaybackStartup(
|
|
||||||
nativeHandle = session.nativeHandle(),
|
|
||||||
size = session.size(),
|
|
||||||
startPosition = watch?.positionSecs ?: 0.0,
|
|
||||||
audioLanguage = preferences.audioLanguage,
|
|
||||||
subtitleLanguage = preferences.subtitleLanguage,
|
|
||||||
subtitles = preferences.subtitles,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
host.play(
|
|
||||||
startup.nativeHandle, startup.size, startup.startPosition,
|
|
||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
|
||||||
)
|
|
||||||
session.transferToPlayer()
|
|
||||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
|
||||||
}
|
|
||||||
DisposableEffect(session, host) {
|
|
||||||
onDispose {
|
|
||||||
host.detachSurface()
|
|
||||||
session.releaseNative(host)
|
|
||||||
scope.launch { session.closeEngine() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showControls by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(showControls) {
|
|
||||||
if (showControls) {
|
|
||||||
delay(4000)
|
|
||||||
showControls = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().clickable(
|
|
||||||
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = { showControls = !showControls }
|
|
||||||
)) {
|
|
||||||
AndroidView(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
factory = { context ->
|
|
||||||
SurfaceView(context).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 = showControls,
|
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
if (host is NativeMpvHost) PlayerControls(
|
|
||||||
titleKey,
|
|
||||||
episode,
|
|
||||||
host,
|
|
||||||
isFullscreen,
|
|
||||||
onToggleFullscreen
|
|
||||||
)
|
|
||||||
}
|
|
||||||
playbackError?.let { message ->
|
|
||||||
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PlayerControls(
|
|
||||||
titleKey: String,
|
|
||||||
episode: EpisodeRecord,
|
|
||||||
host: NativeMpvHost,
|
|
||||||
isFullscreen: Boolean,
|
|
||||||
onToggleFullscreen: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val state by host.state.collectAsState()
|
|
||||||
val tracks by host.tracks.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var choosing by remember { mutableStateOf<String?>(null) }
|
|
||||||
LaunchedEffect(state.position.roundToInt() / 5) {
|
|
||||||
if (state.duration > 0.0) withContext(Dispatchers.IO) {
|
|
||||||
NativeRuntime.engine().saveWatchState(
|
|
||||||
episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
|
|
||||||
state.ended || state.position >= state.duration * 0.9,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
|
|
||||||
Slider(
|
|
||||||
value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
|
|
||||||
onValueChange = { host.seek(it.toDouble()) },
|
|
||||||
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
|
|
||||||
)
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = { host.setPaused(!state.paused) }) {
|
|
||||||
Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White)
|
|
||||||
}
|
|
||||||
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
IconButton(onClick = { host.setMuted(!state.muted) }) {
|
|
||||||
Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onToggleFullscreen) {
|
|
||||||
Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconButton(onClick = { choosing = "audio" }) {
|
|
||||||
Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { choosing = "sub" }) {
|
|
||||||
Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " +
|
|
||||||
"Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}",
|
|
||||||
color = Color.White,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
val old = engine.titleTrackPreferences(titleKey)
|
|
||||||
engine.setTitleTrackPreferences(
|
|
||||||
titleKey,
|
|
||||||
if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
|
|
||||||
else old.copy(
|
|
||||||
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
|
|
||||||
subtitles = selected != null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 {
|
|
||||||
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
|
|
||||||
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(tracks, key = MpvTrack::id) { track ->
|
|
||||||
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
|
|
||||||
Text(if (track.selected) "✓ ${track.label}" else track.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTime(seconds: Double): String {
|
|
||||||
val total = seconds.coerceAtLeast(0.0).roundToInt()
|
|
||||||
return "%d:%02d".format(total / 60, total % 60)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.narl.protonstream.playback
|
package io.narl.protonstream.playback
|
||||||
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import io.narl.protonstream.ui.ThumbnailSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
import kotlin.concurrent.withLock
|
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(
|
data class MpvPlaybackState(
|
||||||
val position: Double = 0.0,
|
val position: Double = 0.0,
|
||||||
@@ -24,6 +50,55 @@ data class MpvPlaybackState(
|
|||||||
val paused: Boolean = true,
|
val paused: Boolean = true,
|
||||||
val muted: Boolean = false,
|
val muted: Boolean = false,
|
||||||
val ended: 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,
|
||||||
|
/** How far the demuxer has read ahead, in seconds into the file. */
|
||||||
|
val cachedUntil: Double = 0.0,
|
||||||
|
) {
|
||||||
|
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
||||||
|
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(
|
data class MpvTrack(
|
||||||
@@ -38,21 +113,61 @@ data class MpvTrack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
|
||||||
class NativeMpvHost : LibmpvHost, AutoCloseable {
|
class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
|
||||||
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
|
|
||||||
private val lifecycle = ReentrantLock()
|
private val lifecycle = ReentrantLock()
|
||||||
@Volatile private var closed = false
|
@Volatile private var closed = false
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
private val mutableState = MutableStateFlow(MpvPlaybackState())
|
||||||
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
|
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
|
private var poller: Job? = null
|
||||||
private var currentStreamHandle: Long? = 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 onPlaybackStarted: (() -> Unit)? = null
|
||||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||||
var onExplicitStop: (() -> Unit)? = null
|
var onExplicitStop: (() -> Unit)? = null
|
||||||
|
/**
|
||||||
|
* Where a transport outside the player sends previous/next.
|
||||||
|
*
|
||||||
|
* 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 state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
||||||
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.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
|
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||||
|
|
||||||
override fun attachSurface(surface: Surface) {
|
override fun attachSurface(surface: Surface) {
|
||||||
@@ -64,77 +179,186 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun play(
|
override suspend fun play(
|
||||||
|
media: String,
|
||||||
nativeHandle: ULong,
|
nativeHandle: ULong,
|
||||||
size: ULong,
|
size: ULong,
|
||||||
startPosition: Double,
|
startPosition: Double,
|
||||||
audioLanguage: String?,
|
audioLanguage: String?,
|
||||||
subtitleLanguage: String?,
|
subtitleLanguage: String?,
|
||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
|
hardwareDecoding: Boolean,
|
||||||
) {
|
) {
|
||||||
check(size > 0uL) { "Cannot play an empty stream" }
|
check(size > 0uL) { "Cannot play an empty stream" }
|
||||||
withContext(Dispatchers.IO) {
|
try {
|
||||||
val loaded = withOpenHandle {
|
withContext(Dispatchers.IO) {
|
||||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
val loaded = withOpenHandle {
|
||||||
if (loaded) {
|
nativeLoad(
|
||||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles,
|
||||||
currentStreamHandle = nativeHandle.toLong()
|
hardwareDecoding,
|
||||||
|
).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"
|
||||||
}
|
}
|
||||||
} ?: false
|
|
||||||
check(loaded) {
|
|
||||||
"libmpv rejected the stream or no video surface was available"
|
|
||||||
}
|
}
|
||||||
}
|
withOpenHandle {
|
||||||
withOpenHandle {
|
onPlaybackStarted?.invoke()
|
||||||
onPlaybackStarted?.invoke()
|
startPolling()
|
||||||
startPolling()
|
}
|
||||||
|
} finally {
|
||||||
|
// Whether the next episode arrived or failed to, the gap the
|
||||||
|
// foreground service was holding open is over.
|
||||||
|
mutableAdvancing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
|
/**
|
||||||
|
* 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 seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
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?) {
|
fun selectTrack(track: MpvTrack?) {
|
||||||
val audio = track?.type == "audio"
|
val audio = track?.type == "audio"
|
||||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||||
refreshTracks()
|
refreshTracks()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explicit user stop: stop media, release its stream, and retire the foreground service. */
|
/**
|
||||||
|
* 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() {
|
fun stop() {
|
||||||
val callback = withOpenHandle {
|
val callback = withOpenHandle {
|
||||||
nativeStop(it)
|
nativeStop(it)
|
||||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
// Nothing loaded means nothing to attribute a reading to: a save
|
||||||
currentStreamHandle = null
|
// 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
|
onExplicitStop
|
||||||
}
|
}
|
||||||
callback?.invoke()
|
callback?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun releaseEndedStream() {
|
|
||||||
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||||
|
|
||||||
private fun startPolling() {
|
private fun startPolling() {
|
||||||
if (poller?.isActive == true) return
|
if (poller?.isActive == true) return
|
||||||
poller = scope.launch {
|
// 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
|
var tick = 0
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val values = withOpenHandle(::nativeState) ?: break
|
val values = withOpenHandle(::nativeState) ?: break
|
||||||
if (values.size >= 6) {
|
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)
|
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,
|
||||||
|
cachedUntil = values[CACHED_UNTIL],
|
||||||
|
)
|
||||||
mutableState.value = next
|
mutableState.value = next
|
||||||
onStateChanged?.invoke(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()
|
if (tick++ % 4 == 0) refreshTracks()
|
||||||
delay(250)
|
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() {
|
private fun refreshTracks() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||||
@@ -149,16 +373,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
lifecycle.withLock {
|
val destroy = lifecycle.withLock {
|
||||||
if (closed) return@withLock
|
if (closed) return@withLock false
|
||||||
closed = true
|
closed = true
|
||||||
onPlaybackStarted = null
|
onPlaybackStarted = null
|
||||||
onStateChanged = null
|
onStateChanged = null
|
||||||
onExplicitStop = null
|
onExplicitStop = null
|
||||||
nativeDestroy(nativeHandle)
|
onSkipPrevious = null
|
||||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
onSkipNext = null
|
||||||
currentStreamHandle = 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. */
|
/** Serializes destruction against every JNI use of the raw Player pointer. */
|
||||||
@@ -166,25 +402,63 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
|
|||||||
if (closed) null else block(nativeHandle)
|
if (closed) null else block(nativeHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
private external fun nativeCreate(): Long
|
|
||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||||
private external fun nativeDetachSurface(handle: Long)
|
private external fun nativeDetachSurface(handle: Long)
|
||||||
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
|
private external fun nativeLoad(
|
||||||
|
handle: Long,
|
||||||
|
stream: Long,
|
||||||
|
start: Double,
|
||||||
|
audio: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
subtitles: Boolean,
|
||||||
|
hardwareDecoding: Boolean,
|
||||||
|
): Boolean
|
||||||
private external fun nativePause(handle: Long, paused: Boolean)
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
private external fun nativeSeek(handle: Long, position: Double)
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
private external fun nativeVolume(handle: Long, volume: Double)
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
|
private external fun nativeSpeed(handle: Long, speed: Double)
|
||||||
private external fun nativeMute(handle: Long, muted: Boolean)
|
private external fun nativeMute(handle: Long, muted: Boolean)
|
||||||
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
|
||||||
private external fun nativeStop(handle: Long)
|
private external fun nativeStop(handle: Long)
|
||||||
private external fun nativeState(handle: Long): DoubleArray
|
private external fun nativeState(handle: Long): DoubleArray
|
||||||
private external fun nativeTracks(handle: Long): String
|
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 external fun pstrAndroidStreamRelease(handle: Long)
|
||||||
|
|
||||||
|
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||||
|
private const val FIELDS = 14
|
||||||
|
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
|
||||||
|
private const val CACHED_UNTIL = 13
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
System.loadLibrary("pstr_mpv")
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,12 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
|
|||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import io.narl.protonstream.MainActivity
|
import io.narl.protonstream.MainActivity
|
||||||
|
import io.narl.protonstream.R
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
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. */
|
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||||
class PlaybackService : Service() {
|
class PlaybackService : Service() {
|
||||||
inner class PlaybackBinder : Binder() {
|
inner class PlaybackBinder : Binder() {
|
||||||
val host: NativeMpvHost get() = this@PlaybackService.host
|
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var host: NativeMpvHost
|
/**
|
||||||
|
* 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 mediaSession: MediaSession
|
||||||
private lateinit var audioManager: AudioManager
|
private lateinit var audioManager: AudioManager
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private var audioFocus: AudioFocusRequest? = null
|
private var audioFocus: AudioFocusRequest? = null
|
||||||
private var notifiedPaused: Boolean? = 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 foregroundPlayback = false
|
||||||
private var sessionPlayback = 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()
|
private val binder = PlaybackBinder()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createChannel()
|
createChannel()
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
becomingNoisy,
|
||||||
|
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
audioManager = getSystemService(AudioManager::class.java)
|
audioManager = getSystemService(AudioManager::class.java)
|
||||||
host = NativeMpvHost().apply {
|
host = NativeMpvHost.createOrNull()?.apply {
|
||||||
onPlaybackStarted = {
|
onPlaybackStarted = {
|
||||||
requestAudioFocus()
|
// 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
|
sessionPlayback = true
|
||||||
if (SettingsStore(this@PlaybackService).backgroundAudio) {
|
cancelRetire()
|
||||||
|
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
|
||||||
// Convert the bound service into a started foreground
|
// Convert the bound service into a started foreground
|
||||||
// service only when the viewer opted to keep audio alive.
|
// service only when the viewer opted to keep audio alive.
|
||||||
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
// Already-foreground is the ordinary case across an episode
|
||||||
startForeground(NOTIFICATION_ID, notification(false))
|
// transition, and re-entering costs a notification rebuild.
|
||||||
foregroundPlayback = true
|
foregroundPlayback = runCatching {
|
||||||
|
startService(Intent(this@PlaybackService, PlaybackService::class.java))
|
||||||
|
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
|
||||||
|
}.isSuccess
|
||||||
}
|
}
|
||||||
mediaSession.isActive = true
|
mediaSession.isActive = true
|
||||||
}
|
}
|
||||||
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
|
|||||||
onExplicitStop = { retirePlayback() }
|
onExplicitStop = { retirePlayback() }
|
||||||
}
|
}
|
||||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||||
|
setSessionActivity(openActivity())
|
||||||
setCallback(object : MediaSession.Callback() {
|
setCallback(object : MediaSession.Callback() {
|
||||||
override fun onPlay() = host.setPaused(false)
|
override fun onPlay() { host?.setPaused(false) }
|
||||||
override fun onPause() = host.setPaused(true)
|
override fun onPause() { host?.setPaused(true) }
|
||||||
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0)
|
override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
|
||||||
override fun onStop() = host.stop()
|
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 onBind(intent: Intent?): IBinder = binder
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_PLAY -> host.setPaused(false)
|
ACTION_PLAY -> host?.setPaused(false)
|
||||||
ACTION_PAUSE -> host.setPaused(true)
|
ACTION_PAUSE -> host?.setPaused(true)
|
||||||
ACTION_STOP -> host.stop()
|
ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
|
||||||
|
ACTION_NEXT -> host?.onSkipNext?.invoke()
|
||||||
|
ACTION_STOP -> host?.stop()
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
runCatching { unregisterReceiver(becomingNoisy) }
|
||||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
host.close()
|
host?.close()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestAudioFocus() {
|
/**
|
||||||
|
* 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)
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||||
)
|
)
|
||||||
|
.setWillPauseWhenDucked(false)
|
||||||
.setOnAudioFocusChangeListener { focus ->
|
.setOnAudioFocusChangeListener { focus ->
|
||||||
when (focus) {
|
when (focus) {
|
||||||
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||||
AudioManager.AUDIOFOCUS_GAIN -> Unit
|
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()
|
}.build()
|
||||||
audioFocus = request
|
audioFocus = request
|
||||||
audioManager.requestAudioFocus(request)
|
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSession(state: MpvPlaybackState) {
|
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 {
|
val status = when {
|
||||||
state.ended -> PlaybackState.STATE_STOPPED
|
state.ended -> PlaybackState.STATE_STOPPED
|
||||||
state.paused -> PlaybackState.STATE_PAUSED
|
state.paused -> PlaybackState.STATE_PAUSED
|
||||||
else -> PlaybackState.STATE_PLAYING
|
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(
|
mediaSession.setPlaybackState(
|
||||||
PlaybackState.Builder()
|
PlaybackState.Builder()
|
||||||
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
|
.setActions(actions)
|
||||||
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
mediaSession.setMetadata(
|
mediaSession.setMetadata(metadata(state, playing))
|
||||||
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(),
|
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 (state.ended && foregroundPlayback) {
|
if (foregroundPlayback && notified != shape) {
|
||||||
host.releaseEndedStream()
|
notified = shape
|
||||||
retirePlayback()
|
getSystemService(NotificationManager::class.java)
|
||||||
return
|
.notify(NOTIFICATION_ID, notification(state, playing))
|
||||||
}
|
|
||||||
if (foregroundPlayback && notifiedPaused != state.paused) {
|
|
||||||
notifiedPaused = state.paused
|
|
||||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
private fun retirePlayback() {
|
||||||
|
cancelRetire()
|
||||||
if (!sessionPlayback && !foregroundPlayback) return
|
if (!sessionPlayback && !foregroundPlayback) return
|
||||||
sessionPlayback = false
|
sessionPlayback = false
|
||||||
notifiedPaused = null
|
notified = null
|
||||||
|
artwork = null
|
||||||
mediaSession.isActive = false
|
mediaSession.isActive = false
|
||||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||||
audioFocus = null
|
audioFocus = null
|
||||||
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notification(paused: Boolean): Notification {
|
private fun openActivity(): PendingIntent = PendingIntent.getActivity(
|
||||||
val open = PendingIntent.getActivity(
|
this,
|
||||||
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
0,
|
||||||
)
|
Intent(this, MainActivity::class.java),
|
||||||
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
val toggle = PendingIntent.getService(
|
)
|
||||||
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
)
|
private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
|
||||||
val stop = PendingIntent.getService(
|
this,
|
||||||
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
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)
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||||
.setContentTitle("proton-stream")
|
.setContentTitle(playing?.title ?: "proton-stream")
|
||||||
.setContentText(if (paused) "Playback paused" else "Playing")
|
.setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
|
||||||
.setContentIntent(open)
|
.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)
|
.setOngoing(!paused)
|
||||||
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build())
|
.apply { actions.forEach { addAction(it) } }
|
||||||
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
|
.setStyle(style)
|
||||||
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createChannel() {
|
private fun createChannel() {
|
||||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
|
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 {
|
companion object {
|
||||||
private const val CHANNEL_ID = "playback"
|
private const val CHANNEL_ID = "playback"
|
||||||
private const val NOTIFICATION_ID = 47
|
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_PLAY = "io.narl.protonstream.PLAY"
|
||||||
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
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"
|
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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,
|
||||||
|
hardwareDecoding: 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}"
|
||||||
@@ -2,6 +2,13 @@ package io.narl.protonstream.settings
|
|||||||
|
|
||||||
import android.content.Context
|
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) {
|
class SettingsStore(context: Context) {
|
||||||
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
@@ -13,9 +20,34 @@ class SettingsStore(context: Context) {
|
|||||||
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
||||||
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
||||||
|
|
||||||
private companion object {
|
/**
|
||||||
const val NAME = "proton_stream_settings"
|
* Whether mpv may decode in hardware. Off is the way out for a device whose
|
||||||
const val KEY_WIFI_ONLY = "wifi_only"
|
* MediaCodec decoder shows green or torn frames; it costs battery, so it is
|
||||||
const val KEY_BACKGROUND_AUDIO = "background_audio"
|
* on unless the viewer turns it off. Read at each load, so a change applies
|
||||||
|
* from the next file.
|
||||||
|
*/
|
||||||
|
var hardwareDecoding: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_HARDWARE_DECODING, true)
|
||||||
|
set(value) { preferences.edit().putBoolean(KEY_HARDWARE_DECODING, value).apply() }
|
||||||
|
|
||||||
|
/** The streaming cache's budget, in GiB. See [CACHE_BUDGET_CHOICES]. */
|
||||||
|
var cacheBudgetGib: Int
|
||||||
|
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||||
|
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||||
|
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||||
|
|
||||||
|
/** `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing. */
|
||||||
|
const val DEFAULT_CACHE_BUDGET_GIB = 4
|
||||||
|
|
||||||
|
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||||
|
|
||||||
|
private const val NAME = "proton_stream_settings"
|
||||||
|
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||||
|
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||||
|
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||||
|
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,17 +25,31 @@ import kotlinx.coroutines.flow.callbackFlow
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
|
import uniffi.pstr_android.LibrarySort
|
||||||
import uniffi.pstr_android.ShareRecord
|
import uniffi.pstr_android.ShareRecord
|
||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.OfflineRecord
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.MatchSummary
|
||||||
import uniffi.pstr_android.MetadataProvider
|
import uniffi.pstr_android.MetadataProvider
|
||||||
import uniffi.pstr_android.MetadataSettingsRecord
|
import uniffi.pstr_android.MetadataSettingsRecord
|
||||||
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.WatchStateRecord
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val titles: List<TitleRecord> = emptyList(),
|
val titles: List<TitleRecord> = emptyList(),
|
||||||
|
/**
|
||||||
|
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||||
|
* [grouped], and the shelves above it. Null until the bridge has answered,
|
||||||
|
* when the grid falls back to [titles] as they are.
|
||||||
|
*/
|
||||||
|
val arrangement: ArrangementRecord? = null,
|
||||||
|
val sort: LibrarySort = LibrarySort.NAME,
|
||||||
|
val grouped: Boolean = true,
|
||||||
val shares: List<ShareRecord> = emptyList(),
|
val shares: List<ShareRecord> = emptyList(),
|
||||||
val offline: List<OfflineRecord> = emptyList(),
|
val offline: List<OfflineRecord> = emptyList(),
|
||||||
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
||||||
@@ -44,9 +58,18 @@ data class AppUiState(
|
|||||||
language = "en",
|
language = "en",
|
||||||
ready = true,
|
ready = true,
|
||||||
),
|
),
|
||||||
|
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
|
/** What Undo on [message] puts back, when the message offers one. */
|
||||||
|
val undo: List<WatchSnapshot>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One episode's watch state as it was before a change the viewer can undo.
|
||||||
|
* Null [state] means it had never been played.
|
||||||
|
*/
|
||||||
|
data class WatchSnapshot(val shareId: String, val linkId: String, val state: WatchStateRecord?)
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
@@ -55,6 +78,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
if (!NativeRuntime.tlsReady) {
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
reload()
|
reload()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||||
@@ -68,6 +96,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reorder the grid, or fold or unfold its franchises. */
|
||||||
|
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||||
|
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||||
|
viewModelScope.launch { reloadLibrary(mutableState.value.query) }
|
||||||
|
}
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
mutableState.update { it.copy(query = query) }
|
mutableState.update { it.copy(query = query) }
|
||||||
searchQuery.value = query
|
searchQuery.value = query
|
||||||
@@ -83,6 +117,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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?) {
|
fun addShare(name: String, url: String, password: String?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -96,6 +167,28 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
fun removeShare(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -117,7 +210,101 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissMessage() = mutableState.update { it.copy(message = null) }
|
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||||
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
|
* menu should not cost them.
|
||||||
|
*/
|
||||||
|
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||||
|
val episodes = title.seasons.flatMap { it.episodes }
|
||||||
|
changeWatch(
|
||||||
|
episodes,
|
||||||
|
"${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
|
||||||
|
) { engine, episode, before ->
|
||||||
|
val duration = before?.durationSecs
|
||||||
|
engine.saveWatchState(
|
||||||
|
episode.shareId,
|
||||||
|
episode.linkId,
|
||||||
|
if (watched) duration ?: 0.0 else 0.0,
|
||||||
|
duration,
|
||||||
|
watched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off Continue watching by forgetting where the episode stopped — what the
|
||||||
|
* desktop does — with an Undo that puts the position back.
|
||||||
|
*/
|
||||||
|
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
|
||||||
|
changeWatch(
|
||||||
|
listOf(episode),
|
||||||
|
"${title.displayName} removed from Continue watching",
|
||||||
|
) { engine, target, before ->
|
||||||
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off the history page: the position and the watched mark both go, which
|
||||||
|
* is "never played", with an Undo.
|
||||||
|
*/
|
||||||
|
fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
|
||||||
|
changeWatch(
|
||||||
|
listOf(episode),
|
||||||
|
"${episode.label} of ${title.displayName} removed from history",
|
||||||
|
) { engine, target, before ->
|
||||||
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put back what the last undoable change replaced. */
|
||||||
|
fun undo() {
|
||||||
|
val snapshots = mutableState.value.undo ?: return
|
||||||
|
mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
snapshots.forEach { snapshot ->
|
||||||
|
val state = snapshot.state
|
||||||
|
engine.saveWatchState(
|
||||||
|
snapshot.shareId,
|
||||||
|
snapshot.linkId,
|
||||||
|
state?.positionSecs ?: 0.0,
|
||||||
|
state?.durationSecs,
|
||||||
|
state?.watched ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeWatch(
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
message: String,
|
||||||
|
change: (uniffi.pstr_android.AndroidEngine, EpisodeRecord, WatchStateRecord?) -> Unit,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
episodes.map { episode ->
|
||||||
|
val before = engine.watchState(episode.shareId, episode.linkId)
|
||||||
|
change(engine, episode, before)
|
||||||
|
WatchSnapshot(episode.shareId, episode.linkId, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess { snapshots ->
|
||||||
|
mutableState.update { it.copy(message = message, undo = snapshots) }
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun reportError(error: Throwable) {
|
fun reportError(error: Throwable) {
|
||||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
@@ -132,8 +319,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
engine.setMetadataApiKey(provider, apiKey)
|
engine.setMetadataApiKey(provider, apiKey)
|
||||||
}
|
}
|
||||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||||
if (enabled) engine.matchTitles(false)
|
if (enabled) engine.matchTitles(false) else null
|
||||||
}
|
}
|
||||||
|
}.onSuccess { summary ->
|
||||||
|
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
||||||
}.onFailure(::reportError)
|
}.onFailure(::reportError)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
@@ -141,6 +330,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
|
|
||||||
fun reloadAfterMetadataChange() = 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) {
|
fun removeOffline(file: OfflineRecord) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -170,16 +376,99 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
private fun reload() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val engine = NativeRuntime.engine()
|
val engine = NativeRuntime.engine()
|
||||||
|
// Here rather than inside `library()`, which is read-only
|
||||||
|
// and cached: a full reload is the one place that has
|
||||||
|
// already paid for a walk of the offline files.
|
||||||
|
engine.pruneOfflineFiles()
|
||||||
|
val current = mutableState.value
|
||||||
|
val query = current.query.takeIf(String::isNotBlank)
|
||||||
Reloaded(
|
Reloaded(
|
||||||
engine.shares(),
|
engine.shares(),
|
||||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
engine.library(query),
|
||||||
|
engine.arrangement(query, current.sort, current.grouped),
|
||||||
engine.offlineFiles(),
|
engine.offlineFiles(),
|
||||||
engine.metadataSettings(),
|
engine.metadataSettings(),
|
||||||
|
engine.storageUsage(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { result ->
|
}.onSuccess { result ->
|
||||||
@@ -187,8 +476,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
loading = false,
|
loading = false,
|
||||||
shares = result.shares,
|
shares = result.shares,
|
||||||
titles = result.titles,
|
titles = result.titles,
|
||||||
|
arrangement = result.arrangement,
|
||||||
offline = result.offline,
|
offline = result.offline,
|
||||||
metadataSettings = result.metadataSettings,
|
metadataSettings = result.metadataSettings,
|
||||||
|
storage = result.storage,
|
||||||
) }
|
) }
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
@@ -198,10 +489,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadLibrary(query: String) {
|
private suspend fun reloadLibrary(query: String) {
|
||||||
|
val current = mutableState.value
|
||||||
runCatching {
|
runCatching {
|
||||||
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
|
withContext(Dispatchers.IO) {
|
||||||
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
|
val engine = NativeRuntime.engine()
|
||||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
val search = query.takeIf(String::isNotBlank)
|
||||||
|
engine.library(search) to engine.arrangement(search, current.sort, current.grouped)
|
||||||
|
}
|
||||||
|
}.onSuccess { (titles, arrangement) ->
|
||||||
|
mutableState.update { it.copy(titles = titles, arrangement = arrangement) }
|
||||||
|
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||||
@@ -213,8 +510,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
private data class Reloaded(
|
private data class Reloaded(
|
||||||
val shares: List<ShareRecord>,
|
val shares: List<ShareRecord>,
|
||||||
val titles: List<TitleRecord>,
|
val titles: List<TitleRecord>,
|
||||||
|
val arrangement: ArrangementRecord,
|
||||||
val offline: List<OfflineRecord>,
|
val offline: List<OfflineRecord>,
|
||||||
val metadataSettings: MetadataSettingsRecord,
|
val metadataSettings: MetadataSettingsRecord,
|
||||||
|
val storage: StorageUsageRecord,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
|
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarColors
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab's page: its own name in an app bar, over whatever the page holds.
|
||||||
|
*
|
||||||
|
* One bar for the whole app said "proton-stream" on every page, which is a band
|
||||||
|
* of height spent naming the app the viewer is already in. The bar draws under
|
||||||
|
* the status bar itself, which is why the shell hands pages no top padding;
|
||||||
|
* [content] gets the rest of [padding] (the navigation bar and the mini
|
||||||
|
* transport) to apply as it scrolls.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun TabPage(
|
||||||
|
title: String,
|
||||||
|
padding: PaddingValues,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
// Pinned: the bar stays, and takes the container tint once the page has
|
||||||
|
// scrolled under it, so the edge between the two is still visible.
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
colors = pageBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f)) { content(padding) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's own background until something scrolls under the bar, then the
|
||||||
|
* container tint. A bar in `surface` from the start read as a band across the
|
||||||
|
* top of a page drawn in `background`.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared-element scopes the library-to-title switch runs in, or null where
|
||||||
|
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
|
||||||
|
* than parameters, because the poster that animates is three calls deep in the
|
||||||
|
* grid and nothing between needs to know.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
|
||||||
|
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title's art as one element across the switch: the poster tapped in the
|
||||||
|
* grid grows into the backdrop of the page it opens, and shrinks back on the
|
||||||
|
* way out, so the page is visibly *that* title's rather than a new screen.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.sharedArt(key: String): Modifier {
|
||||||
|
val transition = LocalSharedTransition.current ?: return this
|
||||||
|
val visibility = LocalPaneVisibility.current ?: return this
|
||||||
|
return with(transition) {
|
||||||
|
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EmptyState(title: String, body: String) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Play on a title lands: the most recently played part-watched episode,
|
||||||
|
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||||
|
*/
|
||||||
|
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||||
|
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||||
|
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
|
||||||
|
internal fun formatBytes(bytes: ULong): String {
|
||||||
|
val value = bytes.toDouble()
|
||||||
|
return when {
|
||||||
|
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
|
||||||
|
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
|
||||||
|
value >= 1024 -> "%.1f KiB".format(value / 1024)
|
||||||
|
else -> "$bytes bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an episode row is headed with: "1. Mother and Children".
|
||||||
|
*
|
||||||
|
* The provider's name for the episode, else the name the filename gave it,
|
||||||
|
* else "Episode 1". The bare filename only when none of those exist — a column
|
||||||
|
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
||||||
|
*/
|
||||||
|
internal fun EpisodeRecord.heading(position: Int): String {
|
||||||
|
val named = providerName ?: detail.takeIf { it != name }
|
||||||
|
val number = number?.toInt()
|
||||||
|
return when {
|
||||||
|
named != null && number != null -> "$number. $named"
|
||||||
|
named != null -> named
|
||||||
|
number != null -> "Episode $number"
|
||||||
|
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
|
||||||
|
/** One show's saved episodes, and the show itself where the library has it. */
|
||||||
|
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DownloadsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onRemove: (OfflineRecord) -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||||
|
val downloads by work.observeAsState(emptyList())
|
||||||
|
// Reading retained metadata on every WorkInfo transition also hydrates
|
||||||
|
// paused/failed/cancelled entries after WorkManager history is pruned.
|
||||||
|
val retained = remember(downloads) { DownloadStateStore(context).records() }
|
||||||
|
// Which show each saved file is from. The offline record knows its episode
|
||||||
|
// but not its show, and the library is the only thing that does.
|
||||||
|
val groups = remember(state.offline, state.titles) {
|
||||||
|
val shows = state.titles.flatMap { title ->
|
||||||
|
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||||
|
}.toMap()
|
||||||
|
state.offline
|
||||||
|
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||||
|
.map { (title, files) ->
|
||||||
|
OfflineGroup(title?.displayName ?: "Not in the library", title, files)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||||
|
// keep it, and getting it back is a download, not a tap.
|
||||||
|
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
|
||||||
|
TabPage("Downloads", padding) { body ->
|
||||||
|
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||||
|
}
|
||||||
|
return@TabPage
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(body),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
if (state.offline.isNotEmpty()) {
|
||||||
|
item(key = "summary") {
|
||||||
|
Text(
|
||||||
|
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
|
||||||
|
"${formatBytes(state.offline.sumOf { it.size })} on this device",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What is still arriving comes first: it is the part of the page
|
||||||
|
// that changes, and the part with something to do.
|
||||||
|
if (retained.isNotEmpty()) {
|
||||||
|
item(key = "in-progress") { SectionHeading("In progress") }
|
||||||
|
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
|
||||||
|
PartialRow(download, onPause, onResume, onDeletePartial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Under the show they belong to, as on desktop: a saved season is
|
||||||
|
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
||||||
|
groups.forEach { group ->
|
||||||
|
item(key = "group/${group.name}") {
|
||||||
|
SectionHeading(
|
||||||
|
group.name,
|
||||||
|
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
|
||||||
|
formatBytes(group.files.sumOf { it.size }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||||
|
SavedRow(file, group.title, onDelete = { deleting = file })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleting?.let { file ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleting = null },
|
||||||
|
title = { Text("Delete this download?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
|
||||||
|
"It still streams, and can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = {
|
||||||
|
deleting = null
|
||||||
|
onRemove(file)
|
||||||
|
}) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeading(title: String, caption: String? = null) {
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
caption?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||||
|
@Composable
|
||||||
|
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
|
||||||
|
val episode = file.episode
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode?.stillUrl ?: title?.backdropUrl,
|
||||||
|
title?.name ?: file.linkId,
|
||||||
|
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
|
||||||
|
fallback = episode?.thumbnailSource,
|
||||||
|
labelled = episode == null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A download not yet finished: how far it has got, why it stopped if it did,
|
||||||
|
* and pause or resume beside cancel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PartialRow(
|
||||||
|
download: RetainedDownload,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
) {
|
||||||
|
val active = download.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
download.status.replaceFirstChar { it.uppercase() },
|
||||||
|
download.total.takeIf { it > 0L }?.let {
|
||||||
|
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||||
|
},
|
||||||
|
).joinToString(" · "),
|
||||||
|
)
|
||||||
|
if (download.total > 0L) {
|
||||||
|
AccentProgress(
|
||||||
|
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (active) {
|
||||||
|
IconButton(onClick = { onPause(download) }) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { onResume(download) }) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What has arrived so far is discarded; nothing finished is.
|
||||||
|
IconButton(onClick = { onDeletePartial(download) }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
|
||||||
|
/** One played episode, with the title it belongs to and its place in that title's playlist. */
|
||||||
|
internal data class Played(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every episode that has been started or finished, newest first — the
|
||||||
|
* desktop's `Library::history`, over the records the library already holds.
|
||||||
|
*
|
||||||
|
* A row marked unwatched again sits at position zero, which is "never played"
|
||||||
|
* for every purpose here, so it is left out. Progress stands in for the
|
||||||
|
* position: it is non-zero exactly when the position is.
|
||||||
|
*/
|
||||||
|
internal fun history(titles: List<TitleRecord>): List<Played> =
|
||||||
|
titles.flatMap { title ->
|
||||||
|
title.seasons.flatMap(SeasonRecord::episodes).withIndex()
|
||||||
|
.filter { (_, episode) -> episode.watched || (episode.progress ?: 0.0) > 0.0 }
|
||||||
|
.map { (index, episode) -> Played(title, episode, index) }
|
||||||
|
}.sortedByDescending { it.episode.lastPlayed }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Today", "Yesterday", a weekday within the last week, then a date — how the
|
||||||
|
* history page heads each day.
|
||||||
|
*/
|
||||||
|
internal fun dayHeading(day: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when {
|
||||||
|
day == today -> "Today"
|
||||||
|
day == today.minusDays(1) -> "Yesterday"
|
||||||
|
day.isAfter(today.minusDays(7)) -> day.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
|
||||||
|
day.year == today.year -> day.format(DateTimeFormatter.ofPattern("d MMMM", locale))
|
||||||
|
else -> day.format(DateTimeFormatter.ofPattern("d MMMM yyyy", locale))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onPlay: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRemove: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
zone: ZoneId = remember { ZoneId.systemDefault() },
|
||||||
|
today: LocalDate = LocalDate.now(zone),
|
||||||
|
) {
|
||||||
|
val days = remember(state.titles) {
|
||||||
|
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||||
|
}
|
||||||
|
if (days.isEmpty()) {
|
||||||
|
TabPage("History", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TabPage("History", padding) { body ->
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
|
days.forEach { (day, played) ->
|
||||||
|
item(key = "day/$day") {
|
||||||
|
Text(
|
||||||
|
dayHeading(day, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||||
|
HistoryRow(
|
||||||
|
entry,
|
||||||
|
onPlay = { onPlay(entry.title, entry.index) },
|
||||||
|
onTitle = { onTitle(entry.title) },
|
||||||
|
onRemove = { onRemove(entry.title, entry.episode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The still plays the episode, the text opens its title, and the cross takes
|
||||||
|
* it off the page — the same three targets the desktop row has.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, onRemove: () -> Unit) {
|
||||||
|
val episode = entry.episode
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(onClick = onPlay),
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
|
episode.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
entry.title.displayName,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
episode.providerName?.let { "${episode.label} · $it" } ?: episode.label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (episode.watched) "Finished" else episode.stoppedAt(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Remove from history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Stopped at 12:04 of 23:40", or as much of it as is known. */
|
||||||
|
private fun EpisodeRecord.stoppedAt(): String {
|
||||||
|
val at = resumeAt ?: return "Started"
|
||||||
|
val duration = progress?.takeIf { it > 0.0 }?.let { at / it }
|
||||||
|
return "Stopped at ${clock(at)}" + (duration?.let { " of ${clock(it)}" } ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clock(seconds: Double): String {
|
||||||
|
val total = seconds.toInt().coerceAtLeast(0)
|
||||||
|
val hours = total / 3_600
|
||||||
|
return if (hours > 0) "%d:%02d:%02d".format(hours, total % 3_600 / 60, total % 60)
|
||||||
|
else "%d:%02d".format(total / 60, total % 60)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The languages offered by name, as the three-letter tag a Matroska file
|
||||||
|
* carries.
|
||||||
|
*
|
||||||
|
* The same short list the desktop names tracks from (`pstr-player`'s
|
||||||
|
* `language_name`): what the shares this app is pointed at actually contain.
|
||||||
|
* mpv treats the two- and three-letter forms of a language as one, so the tag
|
||||||
|
* chosen here also matches a file that says "ja". Anything else is still one
|
||||||
|
* typed tag away.
|
||||||
|
*/
|
||||||
|
internal val LANGUAGES: List<Pair<String, String>> = listOf(
|
||||||
|
"jpn" to "Japanese",
|
||||||
|
"eng" to "English",
|
||||||
|
"ger" to "German",
|
||||||
|
"fre" to "French",
|
||||||
|
"spa" to "Spanish",
|
||||||
|
"ita" to "Italian",
|
||||||
|
"por" to "Portuguese",
|
||||||
|
"dut" to "Dutch",
|
||||||
|
"rus" to "Russian",
|
||||||
|
"pol" to "Polish",
|
||||||
|
"swe" to "Swedish",
|
||||||
|
"dan" to "Danish",
|
||||||
|
"nor" to "Norwegian",
|
||||||
|
"fin" to "Finnish",
|
||||||
|
"cze" to "Czech",
|
||||||
|
"hun" to "Hungarian",
|
||||||
|
"tur" to "Turkish",
|
||||||
|
"ara" to "Arabic",
|
||||||
|
"heb" to "Hebrew",
|
||||||
|
"hin" to "Hindi",
|
||||||
|
"kor" to "Korean",
|
||||||
|
"chi" to "Chinese",
|
||||||
|
"tha" to "Thai",
|
||||||
|
"vie" to "Vietnamese",
|
||||||
|
"ukr" to "Ukrainian",
|
||||||
|
"rum" to "Romanian",
|
||||||
|
"gre" to "Greek",
|
||||||
|
"ind" to "Indonesian",
|
||||||
|
"may" to "Malay",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Japanese", for a tag in any of the forms a file or the desktop may have
|
||||||
|
* stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
|
||||||
|
* Null is no preference.
|
||||||
|
*/
|
||||||
|
internal fun languageLabel(tag: String?): String {
|
||||||
|
val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference"
|
||||||
|
return languageOf(value)?.second ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The listed language a tag names, matching either ISO 639 form. */
|
||||||
|
private fun languageOf(tag: String): Pair<String, String>? {
|
||||||
|
val base = tag.split('-', '_').first().lowercase()
|
||||||
|
return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 639-2/T, for the handful whose bibliographic code differs. */
|
||||||
|
private fun terminology(code: String): String? = when (code) {
|
||||||
|
"ger" -> "deu"
|
||||||
|
"fre" -> "fra"
|
||||||
|
"dut" -> "nld"
|
||||||
|
"cze" -> "ces"
|
||||||
|
"chi" -> "zho"
|
||||||
|
"rum" -> "ron"
|
||||||
|
"gre" -> "ell"
|
||||||
|
"may" -> "msa"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 639-1, the form a file or the desktop may also have stored. */
|
||||||
|
private val TWO_LETTER = mapOf(
|
||||||
|
"jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it",
|
||||||
|
"por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da",
|
||||||
|
"nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar",
|
||||||
|
"heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi",
|
||||||
|
"ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A language preference as a row, opening a list to choose from.
|
||||||
|
*
|
||||||
|
* It was a free-text field asking for "jpn or eng", which is a question only
|
||||||
|
* someone who has read a Matroska header can answer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) {
|
||||||
|
var choosing by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(languageLabel(value)) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(role = Role.Button) { choosing = true },
|
||||||
|
)
|
||||||
|
if (choosing) {
|
||||||
|
LanguageDialog(
|
||||||
|
headline,
|
||||||
|
value,
|
||||||
|
onDismiss = { choosing = false },
|
||||||
|
onChoose = {
|
||||||
|
choosing = false
|
||||||
|
onChange(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) {
|
||||||
|
val current = value?.let(::languageOf)?.first ?: value
|
||||||
|
// A tag that is not on the list starts in the field, so it is shown and
|
||||||
|
// can be edited rather than silently replaced.
|
||||||
|
var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(headline) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
|
||||||
|
item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } }
|
||||||
|
items(LANGUAGES, key = { it.first }) { (code, name) ->
|
||||||
|
LanguageOption(name, current == code) { onChoose(code) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it },
|
||||||
|
label = { Text("Another tag") },
|
||||||
|
supportingText = { Text("As it appears in the file, such as \"tgl\"") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) {
|
||||||
|
Text("Use tag")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) },
|
||||||
|
leadingContent = { RadioButton(selected = selected, onClick = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import java.time.LocalDate
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.LibrarySort
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many shows the continue-watching shelf holds.
|
||||||
|
*
|
||||||
|
* A shelf is a shortcut, not a second library: past about this many it is
|
||||||
|
* quicker to search for the thing than to scroll the shelf looking for it.
|
||||||
|
*/
|
||||||
|
private const val CONTINUE_WATCHING_MAX = 12
|
||||||
|
|
||||||
|
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||||
|
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */
|
||||||
|
private data class Tile(val title: TitleRecord, val folded: Int)
|
||||||
|
|
||||||
|
/** A row above the grid: titles that share a director, a studio or a genre. */
|
||||||
|
private data class Shelf(val label: String, val titles: List<TitleRecord>)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun LibraryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
||||||
|
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
onMatchChanged: () -> Unit = {},
|
||||||
|
onError: (Throwable) -> Unit = {},
|
||||||
|
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
|
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||||
|
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
// Most recently played first, one episode per show: a shelf that lists four
|
||||||
|
// episodes of the same series is a shelf with room for nothing else.
|
||||||
|
val resumable = remember(state.titles) {
|
||||||
|
state.titles.mapNotNull { title ->
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
playlist.withIndex()
|
||||||
|
.filter { it.value.resumeAt != null && !it.value.watched }
|
||||||
|
.maxByOrNull { it.value.lastPlayed }
|
||||||
|
?.let { Resumable(title, it.value, it.index) }
|
||||||
|
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
|
}
|
||||||
|
// Search is an icon until it is wanted: a permanent field above the
|
||||||
|
// library spent a band of every visit on something used in few of them.
|
||||||
|
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
|
||||||
|
val closeSearch = {
|
||||||
|
searching = false
|
||||||
|
onSearch("")
|
||||||
|
}
|
||||||
|
BackHandler(enabled = searching, onBack = closeSearch)
|
||||||
|
// The large title folds into the bar as the grid scrolls, and comes back
|
||||||
|
// only at the top: the page says what it is on arrival, then gets out of
|
||||||
|
// the way.
|
||||||
|
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
if (searching) {
|
||||||
|
SearchBar(state.query, onSearch, closeSearch)
|
||||||
|
} else {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text("Library") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { searching = true }) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||||
|
}
|
||||||
|
ArrangeMenu(state.sort, state.grouped, onArrange)
|
||||||
|
// Pull to refresh is the gesture; the icon stays for a
|
||||||
|
// viewer who does not know it.
|
||||||
|
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = state.refreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.weight(1f).padding(padding),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> LibrarySkeleton()
|
||||||
|
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
||||||
|
"Nothing matches",
|
||||||
|
"No title in the library is called “${state.query.trim()}”.",
|
||||||
|
)
|
||||||
|
state.titles.isEmpty() -> EmptyState(
|
||||||
|
"Your library is empty",
|
||||||
|
"Add a Proton Drive public link under Shares, then refresh.",
|
||||||
|
)
|
||||||
|
else -> LibraryGrid(
|
||||||
|
state,
|
||||||
|
resumable,
|
||||||
|
onResume,
|
||||||
|
onTitle,
|
||||||
|
onTitleMenu = { menuFor = it },
|
||||||
|
onResumeMenu = { menuResume = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menuFor?.let { title ->
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||||
|
TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) {
|
||||||
|
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||||
|
onResume(title, nextUpIndex(playlist))
|
||||||
|
}
|
||||||
|
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||||
|
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||||
|
MenuRow(
|
||||||
|
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
|
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||||
|
) { onSetTitleWatched(title, !allWatched) }
|
||||||
|
// The same search the title page offers, for the tile that is
|
||||||
|
// plainly wrong — the wrong poster is what the grid shows.
|
||||||
|
MenuRow(Icons.Default.Edit, "Change match") { matching = title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matching?.let { title ->
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { matching = null },
|
||||||
|
onChanged = {
|
||||||
|
matching = null
|
||||||
|
onMatchChanged()
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
menuResume?.let { entry ->
|
||||||
|
TileMenu(
|
||||||
|
entry.title.displayName,
|
||||||
|
entry.episode.label,
|
||||||
|
onDismiss = { menuResume = null },
|
||||||
|
) {
|
||||||
|
MenuRow(Icons.Default.PlayArrow, "Resume") { onResume(entry.title, entry.index) }
|
||||||
|
MenuRow(Icons.Default.Info, "Open") { onTitle(entry.title) }
|
||||||
|
MenuRow(Icons.Default.RemoveCircleOutline, "Remove from Continue watching") {
|
||||||
|
onForgetPosition(entry.title, entry.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's order, and whether a franchise is one tile or one per title —
|
||||||
|
* the same choices the desktop offers beside its grid, behind one icon here
|
||||||
|
* because a phone's bar has room for three.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ArrangeMenu(sort: LibrarySort, grouped: Boolean, onArrange: (LibrarySort, Boolean) -> Unit) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { open = true }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort library")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
LibrarySort.entries.forEach { option ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(option.label()) },
|
||||||
|
leadingIcon = {
|
||||||
|
if (option == sort) Icon(Icons.Default.Check, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onArrange(option, grouped)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("One tile per franchise") },
|
||||||
|
leadingIcon = {
|
||||||
|
if (grouped) Icon(Icons.Default.Check, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onArrange(sort, !grouped)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each order is called. The desktop says the same. */
|
||||||
|
private fun LibrarySort.label() = when (this) {
|
||||||
|
LibrarySort.NAME -> "A – Z"
|
||||||
|
LibrarySort.RECENT -> "Recently watched"
|
||||||
|
LibrarySort.ADDED -> "Recently added"
|
||||||
|
LibrarySort.RELEASE -> "Newest"
|
||||||
|
LibrarySort.RATING -> "Highest rated"
|
||||||
|
LibrarySort.POPULARITY -> "Most popular"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bar while searching: back closes the search and clears it, and the field
|
||||||
|
* takes the keyboard as soon as it opens, since opening it is asking to type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
// A filled pill rather than an outlined form field: search is how
|
||||||
|
// the page is navigated, not a value being entered.
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onSearch,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus),
|
||||||
|
singleLine = true,
|
||||||
|
shape = CircleShape,
|
||||||
|
placeholder = { Text("Search library") },
|
||||||
|
trailingIcon = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearch("") }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A long press's menu, from the bottom of the screen where the thumb already
|
||||||
|
* is. Each row closes it before doing its thing.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TileMenu(
|
||||||
|
heading: String,
|
||||||
|
caption: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
rows: @Composable MenuScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.padding(bottom = 24.dp)) {
|
||||||
|
Text(
|
||||||
|
heading,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
caption,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
MenuScope(onDismiss).rows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuScope(val dismiss: () -> Unit)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MenuScope.MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
leadingContent = { Icon(icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { dismiss(); onClick() }.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryGrid(
|
||||||
|
state: AppUiState,
|
||||||
|
resumable: List<Resumable>,
|
||||||
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onTitleMenu: (TitleRecord) -> Unit,
|
||||||
|
onResumeMenu: (Resumable) -> Unit,
|
||||||
|
) {
|
||||||
|
// The arrangement names titles by key; before it has arrived, the titles
|
||||||
|
// as they came are the grid.
|
||||||
|
val byKey = remember(state.titles) { state.titles.associateBy { it.key } }
|
||||||
|
val tiles = remember(state.titles, state.arrangement) {
|
||||||
|
state.arrangement?.tiles?.mapNotNull { tile -> byKey[tile.key]?.let { Tile(it, tile.folded.toInt()) } }
|
||||||
|
?: state.titles.map { Tile(it, 0) }
|
||||||
|
}
|
||||||
|
val shelves = remember(state.titles, state.arrangement) {
|
||||||
|
state.arrangement?.shelves.orEmpty()
|
||||||
|
.map { shelf -> Shelf(shelf.label, shelf.keys.mapNotNull(byKey::get)) }
|
||||||
|
.filter { it.titles.isNotEmpty() }
|
||||||
|
}
|
||||||
|
// Posters, three across on a phone. Cropped backdrops at full width fitted
|
||||||
|
// two titles to a screen, which is a library read one row at a time.
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(108.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
// Above the grid, and only when there is something in it: what a viewer
|
||||||
|
// opening the app wants is almost always the thing they were part way
|
||||||
|
// through. Inside the grid rather than pinned over it, so the shelf
|
||||||
|
// scrolls away with the rest.
|
||||||
|
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||||
|
featured?.let { title ->
|
||||||
|
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
FeaturedBanner(
|
||||||
|
title,
|
||||||
|
resume = resumable.firstOrNull { it.title.key == title.key },
|
||||||
|
onPlay = { onResume(title, it) },
|
||||||
|
onOpen = { onTitle(title) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.query.isBlank() && resumable.isNotEmpty()) {
|
||||||
|
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||||
|
ContinueCard(
|
||||||
|
entry,
|
||||||
|
onClick = { onResume(entry.title, entry.index) },
|
||||||
|
onLongClick = { onResumeMenu(entry) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.query.isBlank()) {
|
||||||
|
shelves.forEach { shelf ->
|
||||||
|
item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Text(shelf.label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(shelf.titles, key = { it.key }) { title ->
|
||||||
|
PosterTile(
|
||||||
|
title,
|
||||||
|
onClick = { onTitle(title) },
|
||||||
|
onLongClick = { onTitleMenu(title) },
|
||||||
|
modifier = Modifier.width(SHELF_TILE_WIDTH),
|
||||||
|
shared = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resumable.isNotEmpty() || shelves.isNotEmpty()) {
|
||||||
|
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(tiles, key = { it.title.key }) { tile ->
|
||||||
|
PosterTile(
|
||||||
|
tile.title,
|
||||||
|
onClick = { onTitle(tile.title) },
|
||||||
|
onLongClick = { onTitleMenu(tile.title) },
|
||||||
|
folded = tile.folded,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's shape before the catalog has answered: posters and their two
|
||||||
|
* lines of text as blocks, so the page does not jump when the titles land.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LibrarySkeleton() {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(108.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||||
|
userScrollEnabled = false,
|
||||||
|
) {
|
||||||
|
items(SKELETON_TILES) {
|
||||||
|
Column {
|
||||||
|
Skeleton(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Skeleton(Modifier.fillMaxWidth(0.8f).height(12.dp).clip(MaterialTheme.shapes.small))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Skeleton(Modifier.fillMaxWidth(0.5f).height(10.dp).clip(MaterialTheme.shapes.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shelf's covers: a little narrower than the grid's, so the row reads as a row. */
|
||||||
|
private val SHELF_TILE_WIDTH = 112.dp
|
||||||
|
|
||||||
|
/** Enough placeholder tiles to fill a tablet's first screen. */
|
||||||
|
private const val SKELETON_TILES = 18
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title the banner shows: the one watched last, or with nothing part
|
||||||
|
* watched, one of the titles the provider has a backdrop for, turning over
|
||||||
|
* once a day — the desktop's `featured`.
|
||||||
|
*/
|
||||||
|
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
|
||||||
|
resumable.firstOrNull()?.let { return it.title }
|
||||||
|
val pictured = titles.filter { it.backdropUrl != null }
|
||||||
|
if (pictured.isEmpty()) return null
|
||||||
|
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Large art behind the name, rating, genres and three lines of synopsis. */
|
||||||
|
@Composable
|
||||||
|
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
title.displayName,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
|
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||||
|
).joinToString(" · ").ifEmpty { title.caption() },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
|
title.overview?.let {
|
||||||
|
Text(
|
||||||
|
it.trim(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(if (resume != null) "Resume" else "Play")
|
||||||
|
}
|
||||||
|
TonalButton(onClick = onOpen) { Text("More info") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
||||||
|
@Composable
|
||||||
|
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(220.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
|
entry.title.displayName,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = entry.episode.thumbnailSource,
|
||||||
|
)
|
||||||
|
entry.episode.progress?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
entry.title.displayName,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One title: its poster, and its name and size under it rather than in a card.
|
||||||
|
*
|
||||||
|
* [folded] is how many more titles of its franchise the tile stands for, said
|
||||||
|
* in the corner. [shared] is whether the poster grows into the title page —
|
||||||
|
* only one copy of a title on screen can, and a shelf may hold the same title
|
||||||
|
* as the grid.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PosterTile(
|
||||||
|
title: TitleRecord,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
folded: Int = 0,
|
||||||
|
shared: Boolean = true,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.posterUrl ?: title.backdropUrl,
|
||||||
|
title.displayName,
|
||||||
|
(if (shared) Modifier.sharedArt(title.key) else Modifier)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clip(MaterialTheme.shapes.medium),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
)
|
||||||
|
if (folded > 0) {
|
||||||
|
Text(
|
||||||
|
"+$folded",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(Color.Black.copy(alpha = 0.7f))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
title.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// Two lines, not one: at a large font scale "2023 · 7 episodes" does
|
||||||
|
// not fit a poster's width, and one line cut it to "2023 · 7".
|
||||||
|
Text(
|
||||||
|
title.caption(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2023 · 11 episodes", or "1988 · Film" — never "1 episodes". */
|
||||||
|
private fun TitleRecord.caption(): String = listOfNotNull(
|
||||||
|
(metadataYear ?: year)?.toString(),
|
||||||
|
when {
|
||||||
|
(metadataKind ?: kind) == TitleType.FILM && episodeCount == 1uL -> "Film"
|
||||||
|
episodeCount == 1uL -> "1 episode"
|
||||||
|
else -> "$episodeCount episodes"
|
||||||
|
},
|
||||||
|
).joinToString(" · ")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "12 min left", from where the episode was left and how far through that is.
|
||||||
|
* Null when either is unknown — a guess would be worse than no figure.
|
||||||
|
*/
|
||||||
|
private fun EpisodeRecord.timeLeft(): String? {
|
||||||
|
val at = resumeAt ?: return null
|
||||||
|
val fraction = progress?.takeIf { it > 0.0 && it < 1.0 } ?: return null
|
||||||
|
val minutes = ((at / fraction - at) / 60).toInt()
|
||||||
|
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
package io.narl.protonstream.ui
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -13,65 +21,310 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
/** HTTPS-only artwork loader backed by the app-private cache directory. */
|
/**
|
||||||
|
* 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. Until either answers, the box pulses as a skeleton; when neither has
|
||||||
|
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||||
|
* initials when [labelled] is false, for art with the name already over it.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) {
|
internal fun RemoteArtwork(
|
||||||
|
url: String?,
|
||||||
|
description: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fallback: ThumbnailSource? = null,
|
||||||
|
labelled: Boolean = true,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
val enabled = LocalArtworkLoading.current
|
||||||
LaunchedEffect(url) {
|
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||||
image = url?.let { requested ->
|
LaunchedEffect(url, fallback, enabled) {
|
||||||
withContext(Dispatchers.IO) {
|
if (!enabled) {
|
||||||
runCatching {
|
image = ArtworkLoad.Missing
|
||||||
val uri = URI(requested)
|
return@LaunchedEffect
|
||||||
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 bytes = if (cached.isFile) cached.readBytes() else {
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
connection.inputStream.use(::readBounded).also { downloaded ->
|
|
||||||
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
|
|
||||||
val temporary = directory.resolve("$digest.part")
|
|
||||||
temporary.writeBytes(downloaded)
|
|
||||||
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||||
|
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||||
|
?: ArtworkLoad.Missing
|
||||||
}
|
}
|
||||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
when (val loaded = image) {
|
||||||
image?.let {
|
ArtworkLoad.Pending -> Skeleton(modifier)
|
||||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
||||||
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed interface ArtworkLoad {
|
||||||
|
data object Pending : ArtworkLoad
|
||||||
|
data object Missing : ArtworkLoad
|
||||||
|
data class Loaded(val image: ImageBitmap) : ArtworkLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [RemoteArtwork] fetches anything at all.
|
||||||
|
*
|
||||||
|
* Off in the screenshot tests: there is no bridge on the host, and whether a
|
||||||
|
* failed fetch lands before or after the capture would decide between a
|
||||||
|
* skeleton and a placeholder in the committed image.
|
||||||
|
*/
|
||||||
|
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something being loaded: the surface tint, breathing.
|
||||||
|
*
|
||||||
|
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||||
|
* says what is coming and where, and settles the layout before it arrives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||||
|
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||||
|
initialValue = 0.55f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||||
|
label = "skeleton alpha",
|
||||||
|
)
|
||||||
|
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art for a title that has none: the accent as a gradient, and its initials.
|
||||||
|
*
|
||||||
|
* A black card with a play triangle on it said nothing about which title it
|
||||||
|
* was, so a grid of unmatched titles was a grid of identical cards. Each name
|
||||||
|
* sets where on the accent ramp its gradient starts, so neighbours differ
|
||||||
|
* while every one stays in the theme's colours.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val brush = remember(name, scheme.primary, scheme.secondary) {
|
||||||
|
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
|
||||||
|
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
|
||||||
|
if (!labelled) return@BoxWithConstraints
|
||||||
|
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
|
||||||
|
Text(
|
||||||
|
initials(name),
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
fontSize = size,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
||||||
|
* capitalised words, so particles and articles in a romanised name do not
|
||||||
|
* take a slot. A name with no capitals uses its first two words.
|
||||||
|
*/
|
||||||
|
internal fun initials(name: String): String {
|
||||||
|
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
|
||||||
|
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
|
||||||
|
return picked.take(2).joinToString("") { it.first().uppercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||||
|
*
|
||||||
|
* The 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_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. */
|
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
|
||||||
private fun readBounded(input: InputStream): ByteArray {
|
private fun readBounded(input: InputStream): ByteArray {
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircle
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import uniffi.pstr_android.FlavorChoice
|
||||||
|
import uniffi.pstr_android.AppearanceRecord
|
||||||
|
import uniffi.pstr_android.TitleNamesChoice
|
||||||
|
import uniffi.pstr_android.AccentChoice
|
||||||
|
import uniffi.pstr_android.PlaybackPrefsRecord
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
onMatchAgain: () -> Unit,
|
||||||
|
onClearCache: () -> Unit,
|
||||||
|
onRemoveAllOffline: () -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
initialPage: SettingsPage? = null,
|
||||||
|
// A parameter so the screenshot tests can pin it: read from the package,
|
||||||
|
// every version bump changed the committed image of this page.
|
||||||
|
version: String = appVersion(),
|
||||||
|
/** The library's names changed, and it has to be read again to show them. */
|
||||||
|
onNamesChanged: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settings = remember { SettingsStore(context) }
|
||||||
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||||
|
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||||
|
// Playback preferences are the shared store's, not this app's: they are the
|
||||||
|
// same file the desktop client reads, so a language chosen on one is the
|
||||||
|
// language the other starts in.
|
||||||
|
var prefs by remember { mutableStateOf<PlaybackPrefsRecord?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
|
||||||
|
val next = change(prefs ?: return)
|
||||||
|
prefs = next
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||||
|
.onSuccess { prefs = it }
|
||||||
|
}
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
var showMetadata by remember { mutableStateOf(false) }
|
||||||
|
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||||
|
// One row per area on the first page, each opening its own page: grouped
|
||||||
|
// on one page, the storage controls sat a long scroll below playback, and
|
||||||
|
// every setting added made the rest harder to find.
|
||||||
|
var page by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
|
BackHandler(enabled = page != null) { page = null }
|
||||||
|
val back: @Composable () -> Unit = {
|
||||||
|
IconButton(onClick = { page = null }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (page) {
|
||||||
|
null -> TabPage("Settings", padding) { body ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
|
SettingsPage.entries.forEach { entry ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(entry.title) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
when (entry) {
|
||||||
|
SettingsPage.Playback -> "Autoplay, languages and decoding"
|
||||||
|
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
|
||||||
|
"${state.storage.offlineCount} offline"
|
||||||
|
SettingsPage.Appearance -> "Palette, accent and gradient"
|
||||||
|
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
|
||||||
|
"On · ${state.metadataSettings.provider.displayName()}"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
}
|
||||||
|
SettingsPage.About -> "Version $version · licences"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { page = entry },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
|
||||||
|
prefs?.let { current ->
|
||||||
|
SettingSwitch(
|
||||||
|
"Autoplay next episode",
|
||||||
|
"Starts the next episode when the credits begin",
|
||||||
|
current.autoplayNext,
|
||||||
|
) { on -> update { it.copy(autoplayNext = on) } }
|
||||||
|
// Off is the safer default: a mis-named chapter then costs a tap on
|
||||||
|
// Skip rather than a scene.
|
||||||
|
SettingSwitch(
|
||||||
|
"Skip openings and endings",
|
||||||
|
"Uses the release's chapters; off shows a Skip button instead",
|
||||||
|
current.autoSkip,
|
||||||
|
) { on -> update { it.copy(autoSkip = on) } }
|
||||||
|
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
||||||
|
update { it.copy(subtitles = on) }
|
||||||
|
}
|
||||||
|
// Preferences, not filters: a file with nothing in the language
|
||||||
|
// plays its own default track. A show that has been given its
|
||||||
|
// own choice keeps it.
|
||||||
|
LanguageSetting("Audio language", current.audioLanguage) { tag ->
|
||||||
|
update { it.copy(audioLanguage = tag) }
|
||||||
|
}
|
||||||
|
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
|
||||||
|
update { it.copy(subtitleLanguage = tag) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"Hardware decoding",
|
||||||
|
"Turn off if video shows green or torn frames",
|
||||||
|
hardwareDecoding,
|
||||||
|
) {
|
||||||
|
hardwareDecoding = it
|
||||||
|
settings.hardwareDecoding = it
|
||||||
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"Background audio",
|
||||||
|
"Keeps playing with the app closed, from the notification",
|
||||||
|
backgroundAudio,
|
||||||
|
) {
|
||||||
|
backgroundAudio = it
|
||||||
|
settings.backgroundAudio = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
|
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||||
|
wifiOnly = it
|
||||||
|
settings.wifiOnly = it
|
||||||
|
// Constraints are baked in at enqueue time, so a queue that already
|
||||||
|
// exists keeps the policy it was queued under until it is re-issued.
|
||||||
|
DownloadCoordinator.applyNetworkPolicy(context)
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Text("Streaming cache")
|
||||||
|
Text(
|
||||||
|
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
AccentProgress(
|
||||||
|
progress = {
|
||||||
|
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
|
||||||
|
.toFloat().coerceIn(0f, 1f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
// What has been watched is kept up to this much, so going back over
|
||||||
|
// a scene costs no download. A lower choice frees the difference at
|
||||||
|
// once. Wrapping, so five chips survive a narrow phone at a large
|
||||||
|
// font scale.
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
|
||||||
|
FilterChip(
|
||||||
|
selected = gib == cacheBudgetGib,
|
||||||
|
onClick = {
|
||||||
|
cacheBudgetGib = gib
|
||||||
|
settings.cacheBudgetGib = gib
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("$gib GiB") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The cache is rebuildable, so it goes without asking. Offline episodes
|
||||||
|
// are a choice the viewer made, so that one asks.
|
||||||
|
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
|
||||||
|
SettingAction(
|
||||||
|
"Delete all offline episodes",
|
||||||
|
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
|
||||||
|
" · ${formatBytes(state.storage.offlineBytes)}" +
|
||||||
|
(
|
||||||
|
state.storage.partialBytes.takeIf { it > 0uL }
|
||||||
|
?.let { " · ${formatBytes(it)} unfinished" }
|
||||||
|
.orEmpty()
|
||||||
|
),
|
||||||
|
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) }
|
||||||
|
}
|
||||||
|
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
|
||||||
|
SettingAction(
|
||||||
|
"Metadata enrichment",
|
||||||
|
if (state.metadataSettings.enabled) {
|
||||||
|
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
|
||||||
|
} else {
|
||||||
|
"Off, the privacy default"
|
||||||
|
},
|
||||||
|
onClick = { showMetadata = true },
|
||||||
|
)
|
||||||
|
// Every title looked up again, matched ones included: the way out of a
|
||||||
|
// library the provider answered wrong, which otherwise stays wrong for
|
||||||
|
// as long as the match is remembered.
|
||||||
|
SettingAction(
|
||||||
|
"Match everything again",
|
||||||
|
"Looks every title up again, hand-picked matches excepted",
|
||||||
|
enabled = state.metadataSettings.enabled && !state.refreshing,
|
||||||
|
onClick = onMatchAgain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Proton Stream") },
|
||||||
|
supportingContent = { Text("Version $version") },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
||||||
|
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
||||||
|
}
|
||||||
|
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
||||||
|
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Delete all offline episodes?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
|
||||||
|
"will be removed from this device. Watch history is kept, and anything " +
|
||||||
|
"deleted can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
legalDocument?.let { document ->
|
||||||
|
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
|
||||||
|
}
|
||||||
|
if (showMetadata) {
|
||||||
|
MetadataSettingsDialog(
|
||||||
|
current = state.metadataSettings,
|
||||||
|
onDismiss = { showMetadata = false },
|
||||||
|
onSave = { enabled, provider, language, key ->
|
||||||
|
onSaveMetadata(enabled, provider, language, key)
|
||||||
|
showMetadata = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The areas the first page lists, each a page of its own. */
|
||||||
|
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
|
||||||
|
Playback("Playback", Icons.Default.PlayCircle),
|
||||||
|
Storage("Downloads and storage", Icons.Default.Storage),
|
||||||
|
Appearance("Appearance", Icons.Default.Palette),
|
||||||
|
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
|
||||||
|
About("About", Icons.Default.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One area's page: its name, a way back, and its rows. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSubPage(
|
||||||
|
page: SettingsPage,
|
||||||
|
padding: PaddingValues,
|
||||||
|
back: @Composable () -> Unit,
|
||||||
|
rows: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
TabPage(page.title, padding, navigationIcon = back) { body ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
|
rows()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installed version, as the launcher and the store show it. */
|
||||||
|
@Composable
|
||||||
|
private fun appVersion(): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember {
|
||||||
|
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
||||||
|
.getOrNull() ?: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A switch with its one line of explanation; the whole row toggles it. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingSwitch(headline: String, supporting: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(supporting) },
|
||||||
|
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onChecked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row that does something when tapped: opens a dialog, clears a cache. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingAction(
|
||||||
|
headline: String,
|
||||||
|
supporting: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(supporting) },
|
||||||
|
colors = if (enabled) {
|
||||||
|
colors
|
||||||
|
} else {
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||||
|
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(enabled = enabled, onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetadataSettingsDialog(
|
||||||
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
|
var language by remember { mutableStateOf(current.language) }
|
||||||
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Metadata enrichment") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.")
|
||||||
|
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MetadataProvider.entries.forEach { option ->
|
||||||
|
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
|
||||||
|
else "Film and television; requires a free TMDB API key.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (provider == MetadataProvider.TMDB) {
|
||||||
|
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
apiKey,
|
||||||
|
{ apiKey = it },
|
||||||
|
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
||||||
|
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||||
|
MetadataProvider.ANI_LIST -> "AniList"
|
||||||
|
MetadataProvider.TMDB -> "TMDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LegalDocument(val title: String, val assetPath: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
|
||||||
|
LaunchedEffect(document.assetPath) {
|
||||||
|
contents = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(document.title) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
|
||||||
|
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Switch(checked = checked, onCheckedChange = onChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flavour, accent and gradients — the same three the desktop client offers.
|
||||||
|
*
|
||||||
|
* Every colour is resolved by Rust, so a swatch here is the colour the app will
|
||||||
|
* actually paint rather than an approximation of it, and the choice is stored in
|
||||||
|
* the file both clients read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppearancePicker(onNamesChanged: () -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
|
||||||
|
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) }
|
||||||
|
|
||||||
|
suspend fun repaint(next: AppearanceRecord, store: Boolean) {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
if (store) engine.setAppearance(next)
|
||||||
|
val palette = engine.previewPalette(next)
|
||||||
|
// Every accent as it would look in *this* flavour: a swatch row
|
||||||
|
// that keeps Mocha's pastels while Latte is selected is a row
|
||||||
|
// that lies about what the next tap does.
|
||||||
|
val row = AccentChoice.entries.associateWith { accent ->
|
||||||
|
Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt())
|
||||||
|
}
|
||||||
|
palette to row
|
||||||
|
}
|
||||||
|
}.onSuccess { (palette, row) ->
|
||||||
|
choice = next
|
||||||
|
swatches = row
|
||||||
|
AppearanceState.apply(palette)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } }
|
||||||
|
.onSuccess { repaint(it, store = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = choice ?: return
|
||||||
|
Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
items(FlavorChoice.entries.toList(), key = { it.name }) { flavor ->
|
||||||
|
val selected = flavor == current.flavor
|
||||||
|
if (selected) {
|
||||||
|
AccentButton(onClick = {}) { Text(flavor.label()) }
|
||||||
|
} else {
|
||||||
|
EdgedButton(onClick = {
|
||||||
|
scope.launch { repaint(current.copy(flavor = flavor), store = true) }
|
||||||
|
}) { Text(flavor.label()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Accent", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
items(AccentChoice.entries.toList(), key = { it.name }) { accent ->
|
||||||
|
val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(swatch)
|
||||||
|
.border(
|
||||||
|
width = if (accent == current.accent) 3.dp else 1.dp,
|
||||||
|
color = if (accent == current.accent) {
|
||||||
|
MaterialTheme.colorScheme.onBackground
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.clickable {
|
||||||
|
scope.launch { repaint(current.copy(accent = accent), store = true) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Title names", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
items(TitleNamesChoice.entries.toList(), key = { it.name }) { names ->
|
||||||
|
if (names == current.names) {
|
||||||
|
AccentButton(onClick = {}) { Text(names.label()) }
|
||||||
|
} else {
|
||||||
|
EdgedButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
repaint(current.copy(names = names), store = true)
|
||||||
|
onNamesChanged()
|
||||||
|
}
|
||||||
|
}) { Text(names.label()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"What a matched title is called. Its other names still find it in search.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
|
||||||
|
scope.launch { repaint(current.copy(gradients = on), store = true) }
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Off is the safer setting on a panel that bands: a slow ramp across a wide bar " +
|
||||||
|
"shows every step it is drawn from, and flat is better than striped.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each name setting is called. The desktop client says the same. */
|
||||||
|
private fun TitleNamesChoice.label() = when (this) {
|
||||||
|
TitleNamesChoice.LIBRARY -> "As in the share"
|
||||||
|
TitleNamesChoice.ENGLISH -> "English"
|
||||||
|
TitleNamesChoice.ROMAJI -> "Romaji"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each palette family is called. The desktop client says the same. */
|
||||||
|
private fun FlavorChoice.label() = when (this) {
|
||||||
|
FlavorChoice.PROTON -> "Proton"
|
||||||
|
FlavorChoice.LATTE -> "Catppuccin Latte"
|
||||||
|
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
|
||||||
|
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
|
||||||
|
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
||||||
|
FlavorChoice.PERSONA5 -> "Persona 5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.FolderShared
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SharesScreen(
|
||||||
|
shares: List<ShareRecord>,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
onRepair: (String, String, String?) -> Unit,
|
||||||
|
onRefresh: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
incomingLink: String?,
|
||||||
|
onIncomingLinkTaken: () -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
TabPage("Shares", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
if (shares.isEmpty()) {
|
||||||
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
|
} else {
|
||||||
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
|
items(shares, key = { it.id }) { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
onRefresh = { onRefresh(share.id) },
|
||||||
|
onRepair = { repairing = share },
|
||||||
|
onRemove = { removing = share },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Where a thumb is, and the one thing this page is for. The share
|
||||||
|
// sheet and a tapped link land in the same form.
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showAdd = true },
|
||||||
|
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
|
text = { Text("Add share") },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAdd || incomingLink != null) {
|
||||||
|
AddShareDialog(
|
||||||
|
initialUrl = incomingLink.orEmpty(),
|
||||||
|
onDismiss = { showAdd = false; onIncomingLinkTaken() },
|
||||||
|
onAdd = onAdd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repairing?.let { share ->
|
||||||
|
RepairShareDialog(
|
||||||
|
share = share,
|
||||||
|
onDismiss = { repairing = null },
|
||||||
|
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
removing?.let { share ->
|
||||||
|
// Asked, because it cannot be undone: the link's secret, its offline
|
||||||
|
// files and its watch positions all go (`Catalog::remove_share`).
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { removing = null },
|
||||||
|
title = { Text("Remove ${share.name}?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Its titles leave the library, and its offline episodes and watch " +
|
||||||
|
"progress are deleted. Adding the link again starts it fresh.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One share: its name, how it is unlocked, and its actions behind a menu.
|
||||||
|
*
|
||||||
|
* Three differently styled controls beside the name squeezed it into a column
|
||||||
|
* a word wide; one icon leaves the row its width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShareRow(
|
||||||
|
share: ShareRecord,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onRepair: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link")
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.FolderShared,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Actions for ${share.name}")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
// One share, not the library: a link that has just had files
|
||||||
|
// added should not cost a walk of every other one.
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Refresh") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRefresh() },
|
||||||
|
)
|
||||||
|
// Not a remove: a share whose secret has become unreadable
|
||||||
|
// cannot be removed either, since removal deletes a secret
|
||||||
|
// the store can no longer touch.
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Re-enter link") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRepair() },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Remove") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRemove() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enter the link for a share the app can no longer decrypt its secret for.
|
||||||
|
*
|
||||||
|
* It has to be the *same* link — a different token is a different share, and
|
||||||
|
* repointing this one at it would leave every catalog row and every offline file
|
||||||
|
* describing something that is not there. Rust enforces that; this only says so.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RepairShareDialog(
|
||||||
|
share: ShareRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRepair: (String, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Re-enter link for ${share.name}") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"The stored credentials for this share cannot be read — usually after a " +
|
||||||
|
"screen-lock change or a device restore. Entering the same link again " +
|
||||||
|
"restores access without losing the library or anything downloaded.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onRepair(url.trim(), password); onDismiss() },
|
||||||
|
enabled = url.isNotBlank(),
|
||||||
|
) { Text("Restore") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddShareDialog(
|
||||||
|
initialUrl: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Add Proton Drive share") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
// The URL fragment is a secret. Password input disables
|
||||||
|
// IME learning/suggestions while normal long-press paste
|
||||||
|
// remains available.
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
||||||
|
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||||
|
) { Text("Add") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import io.narl.protonstream.ui.theme.solid
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.MatchRecord
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material.icons.filled.Replay
|
||||||
|
import androidx.compose.material.icons.filled.DownloadDone
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TitleScreen(
|
||||||
|
title: TitleRecord,
|
||||||
|
playerReady: Boolean,
|
||||||
|
onPlay: (List<EpisodeRecord>, Int) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPreferenceError: (Throwable) -> Unit,
|
||||||
|
onMetadataChanged: () -> Unit,
|
||||||
|
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
/** Every title of its franchise, in release order, this one included. */
|
||||||
|
franchise: List<TitleRecord> = emptyList(),
|
||||||
|
onOpenTitle: (TitleRecord) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
|
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Predictive back: the page shrinks towards the library as the gesture
|
||||||
|
// is dragged, and springs back if it is let go of, so the viewer sees
|
||||||
|
// where back goes before committing to it.
|
||||||
|
var backProgress by remember { mutableFloatStateOf(0f) }
|
||||||
|
PredictiveBackHandler { gesture ->
|
||||||
|
try {
|
||||||
|
gesture.collect { backProgress = it.progress }
|
||||||
|
onBack()
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
backProgress = 0f
|
||||||
|
throw cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display order across seasons: what previous/next and autoplay walk, and
|
||||||
|
// the same order the desktop client uses.
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||||
|
val upNext = playlist.getOrNull(nextUp)
|
||||||
|
// One season on screen at a time, opened on the one Play would land in: a
|
||||||
|
// forty-episode show as stacked sections is one long scroll to the part
|
||||||
|
// that matters.
|
||||||
|
var seasonIndex by remember(title.key) {
|
||||||
|
mutableStateOf(
|
||||||
|
title.seasons.indexOfFirst { season -> season.episodes.any { it.linkId == upNext?.linkId } }
|
||||||
|
.coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val season = title.seasons.getOrNull(seasonIndex)
|
||||||
|
// Live download state per episode, so a row can show progress and be paused
|
||||||
|
// without a trip to the Downloads tab.
|
||||||
|
val work by WorkManager.getInstance(context)
|
||||||
|
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||||
|
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||||
|
|
||||||
|
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||||
|
// button and synopsis lines two thousand pixels wide are read by turning
|
||||||
|
// the head. The backdrop still spans the window, but no taller than about
|
||||||
|
// half of it, or a landscape tablet opened on a picture and nothing else.
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
val scale = 1f - 0.1f * backProgress
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
shape = RoundedCornerShape((32 * backProgress).dp)
|
||||||
|
clip = backProgress > 0f
|
||||||
|
}
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||||
|
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item(key = "backdrop") {
|
||||||
|
// Edge to edge, fading into the page: the art is the first thing
|
||||||
|
// the page says, and a framed thumbnail with a margin round it read
|
||||||
|
// as one card among the many below it.
|
||||||
|
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.45f to Color.Transparent,
|
||||||
|
1f to MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "heading") {
|
||||||
|
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||||
|
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
// The other names it goes by: the share's when the shown one
|
||||||
|
// is the provider's, and the original-language one.
|
||||||
|
listOfNotNull(
|
||||||
|
title.name.takeIf { it != title.displayName },
|
||||||
|
title.originalName?.takeIf { it != title.displayName && it != title.canonicalName },
|
||||||
|
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.formatLabel,
|
||||||
|
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
|
||||||
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
|
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
// Who made it, and whether more is coming — the desktop's
|
||||||
|
// line of facts, on a line of its own so neither wraps.
|
||||||
|
listOfNotNull(
|
||||||
|
title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "),
|
||||||
|
title.directors.firstOrNull()?.let { "dir. $it" },
|
||||||
|
airingNote(title),
|
||||||
|
).joinToString(" · ").takeIf(String::isNotEmpty)?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The one thing the page is for, full width and naming what it
|
||||||
|
// will play: "Resume" alone left the viewer guessing which
|
||||||
|
// episode of forty it meant.
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onPlay(playlist, nextUp) },
|
||||||
|
enabled = playerReady && playlist.isNotEmpty(),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
!playerReady -> "Player loading…"
|
||||||
|
upNext == null -> "Play"
|
||||||
|
upNext.resumeAt != null -> "Resume ${upNext.label}"
|
||||||
|
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
|
||||||
|
playlist.size == 1 -> "Play"
|
||||||
|
else -> "Play ${upNext.label}"
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Everything else as labelled icons in one row: four actions of
|
||||||
|
// three button styles used to wrap over three lines and bury
|
||||||
|
// the episodes under them.
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
// Only where it would do something different: an unstarted
|
||||||
|
// show already starts at the beginning.
|
||||||
|
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||||
|
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
|
||||||
|
onPlay(playlist, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||||
|
DownloadCoordinator.enqueue(context, playlist)
|
||||||
|
}
|
||||||
|
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||||
|
// The provider's own page for this title: where a viewer goes
|
||||||
|
// to check that the thing the app matched is what they have.
|
||||||
|
title.externalUrl?.let { url ->
|
||||||
|
TitleAction(
|
||||||
|
Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
title.metadataProvider?.displayName() ?: "Provider",
|
||||||
|
Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||||
|
}.onFailure(onPreferenceError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title.overview?.let { overview ->
|
||||||
|
// Three lines, then a tap for the rest: a synopsis with its
|
||||||
|
// source notes ran to a screen and a half on a phone.
|
||||||
|
Text(
|
||||||
|
overview.trim(),
|
||||||
|
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.clickable { overviewOpen = !overviewOpen },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Genres as chips, then the provider's tags quieter after
|
||||||
|
// them: a tag is its community describing it, not what it
|
||||||
|
// is filed as.
|
||||||
|
if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
(title.genres + title.tags.take(4)).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Said plainly, because it changes what a re-match will do: a
|
||||||
|
// hand-picked match is not overwritten by an automatic pass.
|
||||||
|
if (title.manualMatch) {
|
||||||
|
Text(
|
||||||
|
"Matched by hand",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (franchise.size > 1) {
|
||||||
|
item(key = "franchise") {
|
||||||
|
Column(Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
"In this franchise",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = side + 16.dp),
|
||||||
|
)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(franchise, key = { it.key }) { member ->
|
||||||
|
FranchiseTile(member, here = member.key == title.key) { onOpenTitle(member) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "seasons") {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||||
|
FilterChip(
|
||||||
|
selected = index == seasonIndex,
|
||||||
|
onClick = { seasonIndex = index },
|
||||||
|
label = { Text(entry.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
season?.let {
|
||||||
|
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
season?.let { shown ->
|
||||||
|
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||||
|
Box(Modifier.padding(horizontal = side)) {
|
||||||
|
EpisodeRow(
|
||||||
|
episode = episode,
|
||||||
|
numbering = episode.numbering(shown.number, position),
|
||||||
|
position = position,
|
||||||
|
download = downloads.firstOrNull {
|
||||||
|
it.shareId == episode.shareId && it.linkId == episode.linkId
|
||||||
|
},
|
||||||
|
playerReady = playerReady,
|
||||||
|
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
||||||
|
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
||||||
|
onPause = { DownloadCoordinator.pause(context, it) },
|
||||||
|
onResume = { DownloadCoordinator.resume(context, it) },
|
||||||
|
onSetWatched = { onSetWatched(episode, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showMatch) {
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { showMatch = false },
|
||||||
|
onChanged = { showMatch = false; onMetadataChanged() },
|
||||||
|
onError = onPreferenceError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One part of the franchise: its cover, what it is and when. This title's own
|
||||||
|
* is marked rather than left out, so the row says where it sits in the story.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(96.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(enabled = !here, onClick = onClick),
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.posterUrl ?: title.backdropUrl,
|
||||||
|
title.displayName,
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.then(
|
||||||
|
if (here) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
title.displayName,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = if (here) FontWeight.SemiBold else null,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.formatLabel ?: if (title.kind == TitleType.FILM) "Film" else "Series",
|
||||||
|
(title.metadataYear ?: title.year)?.toString(),
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with
|
||||||
|
* no date yet, else nothing.
|
||||||
|
*/
|
||||||
|
private fun airingNote(title: TitleRecord): String? {
|
||||||
|
val at = title.nextAiringAt
|
||||||
|
val episode = title.nextEpisode
|
||||||
|
if (at != null && episode != null) {
|
||||||
|
val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault())
|
||||||
|
return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM"))
|
||||||
|
}
|
||||||
|
return if (title.airing) "airing" else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||||
|
private val READABLE_WIDTH = 720.dp
|
||||||
|
|
||||||
|
/** One of the title's secondary actions: an icon with its name under it. */
|
||||||
|
@Composable
|
||||||
|
private fun TitleAction(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `S03E38`, `E38`, or the row's place in the season.
|
||||||
|
*
|
||||||
|
* Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
|
||||||
|
* fallback to the season folder's number. The last case is deliberately *not*
|
||||||
|
* printed as an episode number — nothing in the name numbered this file, and
|
||||||
|
* `#4` says "fourth in the list" where `E04` would be a claim about the show.
|
||||||
|
* Some ordinal is required regardless: a season of rows that all read the same
|
||||||
|
* is a season the viewer cannot navigate at all.
|
||||||
|
*/
|
||||||
|
private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String {
|
||||||
|
val season = this.season ?: fallbackSeason
|
||||||
|
val number = this.number
|
||||||
|
return when {
|
||||||
|
season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt())
|
||||||
|
number != null -> "E%02d".format(number.toInt())
|
||||||
|
else -> "#${position + 1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One episode: play it, see where it was left, and drive its download.
|
||||||
|
*
|
||||||
|
* The row *is* the play button — the still, the number and the name are one
|
||||||
|
* target, which is what lets the trailing controls be two icons in a fixed
|
||||||
|
* column rather than a second row of buttons under every episode.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeRow(
|
||||||
|
episode: EpisodeRecord,
|
||||||
|
numbering: String,
|
||||||
|
position: Int,
|
||||||
|
download: RetainedDownload?,
|
||||||
|
playerReady: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onSetWatched: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download?.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
val heading = episode.heading(position)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = playerReady, onClick = onPlay)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// With metadata off the provider has no still, and Proton's own
|
||||||
|
// thumbnail is a frame of this very episode. Where it was left sits
|
||||||
|
// on the still's lower edge, the way every streaming app shows it.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl,
|
||||||
|
heading,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
heading,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
// Seen episodes stay legible but stop competing with the
|
||||||
|
// one the viewer has not watched yet.
|
||||||
|
color = if (episode.watched) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
numbering,
|
||||||
|
episode.size?.let(::formatBytes),
|
||||||
|
episode.airDate,
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Watched is a judgement the viewer is allowed to overrule: a
|
||||||
|
// half-watched episode they are done with, or one the 90 % rule
|
||||||
|
// marked seen because they sat through the credits.
|
||||||
|
IconButton(onClick = { onSetWatched(!episode.watched) }) {
|
||||||
|
Icon(
|
||||||
|
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
|
||||||
|
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
|
||||||
|
tint = if (episode.watched) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// One slot, always the same width, whatever state the download
|
||||||
|
// is in — including "there is nothing to say", where an empty
|
||||||
|
// box keeps the rows above and below it aligned.
|
||||||
|
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
when {
|
||||||
|
episode.offline -> Icon(
|
||||||
|
Icons.Default.DownloadDone,
|
||||||
|
contentDescription = "Saved offline",
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
running -> IconButton(onClick = { onPause(download!!) }) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
|
}
|
||||||
|
download != null -> IconButton(onClick = { onResume(download) }) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Resume download")
|
||||||
|
}
|
||||||
|
else -> IconButton(onClick = onDownload) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episode.providerOverview?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
||||||
|
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ChangeMatchDialog(
|
||||||
|
title: TitleRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
onError: (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Change match") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
||||||
|
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
||||||
|
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
|
||||||
|
searching = true
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
|
||||||
|
}.onSuccess { options = it }.onFailure(onError)
|
||||||
|
searching = false
|
||||||
|
}
|
||||||
|
}) { Text(if (searching) "Searching…" else "Search") }
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||||
|
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||||
|
EdgedButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||||
|
}.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(option.name, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
QuietButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||||
|
.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
}) { Text("Forget match") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
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, buffered: Float = 0f) {
|
||||||
|
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)
|
||||||
|
// What is already read ahead, lighter than the rest and under the
|
||||||
|
// played part: a jump that lands in it costs no fetch.
|
||||||
|
val ahead = size.width * buffered.coerceIn(0f, 1f)
|
||||||
|
if (ahead >= size.height) {
|
||||||
|
drawRoundRect(
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
size = Size(ahead, size.height),
|
||||||
|
cornerRadius = radius,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val done = size.width * state.coercedValueAsFraction
|
||||||
|
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 — `theme::radius::BADGE` in pstr-app 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
|
||||||
@@ -1,24 +1,216 @@
|
|||||||
package io.narl.protonstream.ui.theme
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
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 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
|
||||||
|
|
||||||
private val ProtonColors = darkColorScheme(
|
/**
|
||||||
primary = Color(0xFFCBA6F7),
|
* The palette in force, shared across every composition.
|
||||||
onPrimary = Color(0xFF1E1E2E),
|
*
|
||||||
secondary = Color(0xFFF5C2E7),
|
* The choice is stored by Rust and resolved by Rust — the same flavours, the
|
||||||
background = Color(0xFF1E1E2E),
|
* same accent pairings and the same contrast rule the desktop client uses — so
|
||||||
surface = Color(0xFF181825),
|
* what lives here is only the resolved answer and a way to replace it when the
|
||||||
surfaceVariant = Color(0xFF313244),
|
* viewer picks something else.
|
||||||
onBackground = Color(0xFFCDD6F4),
|
*/
|
||||||
onSurface = Color(0xFFCDD6F4),
|
object AppearanceState {
|
||||||
outline = Color(0xFF6C7086),
|
private val current = MutableStateFlow<PaletteRecord?>(null)
|
||||||
error = Color(0xFFF38BA8),
|
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
|
@Composable
|
||||||
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
fun ProtonStreamTheme(content: @Composable () -> Unit) {
|
||||||
MaterialTheme(colorScheme = ProtonColors, content = content)
|
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,95 @@
|
|||||||
|
package io.narl.protonstream.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import io.narl.protonstream.R
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inter, the desktop client's face, from the same two files.
|
||||||
|
*
|
||||||
|
* Only Regular and SemiBold ship, as on the desktop. A `Medium` role resolves
|
||||||
|
* to Regular by the usual weight matching rather than to a synthesised bold,
|
||||||
|
* which is how the desktop draws it too.
|
||||||
|
*
|
||||||
|
* Above [ProtonTypography] on purpose: top-level properties initialise in
|
||||||
|
* file order, and a ramp built first would capture this as null and fall back
|
||||||
|
* to the system face without a word.
|
||||||
|
*/
|
||||||
|
internal val Inter = FontFamily(
|
||||||
|
Font(R.font.inter_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type ramp, which is the desktop client's type ramp.
|
||||||
|
*
|
||||||
|
* `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,
|
||||||
|
fontFamily = Inter,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
The launcher tile: packaging/io.narl.proton-stream.svg's face gradient, on
|
||||||
|
the full 108dp canvas because the launcher cuts its own shape out of it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:type="linear"
|
||||||
|
android:startX="0" android:startY="0"
|
||||||
|
android:endX="108" android:endY="108">
|
||||||
|
<item android:offset="0" android:color="#FFA48BFF" />
|
||||||
|
<item android:offset="0.55" android:color="#FF7552FF" />
|
||||||
|
<item android:offset="1" android:color="#FF5B36F2" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -1,6 +1,34 @@
|
|||||||
|
<!--
|
||||||
|
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
|
||||||
|
revision is fetched in. The paths are in the SVG's 128-unit space; the group
|
||||||
|
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
|
||||||
|
It centres x=70, between the glyph's centroid (66) and its box (73): the
|
||||||
|
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
|
||||||
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp" android:height="108dp"
|
android:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
|
<group
|
||||||
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-16" android:translateY="-8">
|
||||||
|
<group android:translateY="3">
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:fillColor="#4D2E1596" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:type="linear"
|
||||||
|
android:startX="0" android:startY="36"
|
||||||
|
android:endX="0" android:endY="88">
|
||||||
|
<item android:offset="0" android:color="#FFFFFFFF" />
|
||||||
|
<item android:offset="1" android:color="#FFECE6FF" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!-- The foreground's glyph as one flat shape, for themed launcher icons. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-16" android:translateY="-8">
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!--
|
||||||
|
The status-bar glyph. Android tints small icons to a single colour and drops
|
||||||
|
everything that is not opaque, so this is the launcher's sliced play mark as
|
||||||
|
one white silhouette, scaled from the SVG's 128-unit space onto 24dp.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp" android:height="24dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.37" android:scaleY="0.37"
|
||||||
|
android:translateX="-58" android:translateY="-50">
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -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>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">proton-stream</string>
|
<!-- Settings, recents, notifications and the share sheet: room for the full name. -->
|
||||||
|
<string name="app_name">Proton Stream</string>
|
||||||
|
<!--
|
||||||
|
Under the launcher icon, where about ten characters fit before the
|
||||||
|
launcher cuts the label off; "proton-stream" showed as "proton-st…".
|
||||||
|
-->
|
||||||
|
<string name="launcher_name">Stream</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
<resources>
|
<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">
|
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
|
||||||
<item name="android:fontFamily">sans</item>
|
<item name="android:fontFamily">sans</item>
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
<item name="android:windowLightNavigationBar">false</item>
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
<item name="android:windowBackground">#1E1E2E</item>
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IncomingShareLinkTest {
|
||||||
|
private val link = "https://drive.proton.me/urls/ABC123#s3cr3t"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a viewed share link is taken whole, fragment included`() {
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_VIEW, link, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a viewed url that is not an https share link is ignored`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "https://drive.proton.me/u/0/", null))
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "http://drive.proton.me/urls/ABC123#s3cr3t", null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared message yields the link inside it`() {
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "Season two is up: $link enjoy"))
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "(<$link>)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared message without a link yields nothing`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_SEND, null, "no link here"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `other actions are ignored`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_MAIN, link, link))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,28 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ArtworkTest {
|
||||||
|
@Test
|
||||||
|
fun `initials skip the lower-case particles of a romanised name`() {
|
||||||
|
assertEquals("OK", initials("Oshi no Ko"))
|
||||||
|
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-word name has one initial`() {
|
||||||
|
assertEquals("A", initials("Akira"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a name with no capitals falls back to its first two words`() {
|
||||||
|
assertEquals("BW", initials("blue world order"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `punctuation and separators are not words`() {
|
||||||
|
assertEquals("S2", initials("Steins;Gate - 2"))
|
||||||
|
assertEquals("", initials(" - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.Locale
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
class HistoryTest {
|
||||||
|
private fun episode(link: String, watched: Boolean, progress: Double?, lastPlayed: Long) = EpisodeRecord(
|
||||||
|
shareId = "s", volumeId = "v", linkId = link, name = "$link.mkv", label = link, detail = "$link.mkv",
|
||||||
|
season = 1u, number = null, size = null, progress = progress, resumeAt = progress?.let { it * 1000 },
|
||||||
|
watched = watched, offline = false, providerName = null, providerOverview = null, stillUrl = null,
|
||||||
|
airDate = null, lastPlayed = lastPlayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun title(key: String, vararg episodes: EpisodeRecord) = TitleRecord(
|
||||||
|
key = key, name = key, year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||||
|
episodeCount = episodes.size.toULong(), canonicalName = null, originalName = null, overview = null,
|
||||||
|
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
|
||||||
|
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
|
||||||
|
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())),
|
||||||
|
displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||||
|
directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null,
|
||||||
|
franchise = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `history is every started or finished episode, newest first, across titles`() {
|
||||||
|
val titles = listOf(
|
||||||
|
title(
|
||||||
|
"a",
|
||||||
|
episode("a1", watched = true, progress = 1.0, lastPlayed = 30),
|
||||||
|
// Marked unwatched again: a row at zero, which is not a play.
|
||||||
|
episode("a2", watched = false, progress = 0.0, lastPlayed = 40),
|
||||||
|
episode("a3", watched = false, progress = null, lastPlayed = 0),
|
||||||
|
),
|
||||||
|
title("b", episode("b1", watched = false, progress = 0.4, lastPlayed = 50)),
|
||||||
|
)
|
||||||
|
val played = history(titles)
|
||||||
|
assertEquals(listOf("b1", "a1"), played.map { it.episode.linkId })
|
||||||
|
assertEquals(0, played.last().index)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days are headed relative to today, then by date`() {
|
||||||
|
val today = LocalDate.of(2026, 10, 3) // a Saturday
|
||||||
|
val english = Locale.ENGLISH
|
||||||
|
assertEquals("Today", dayHeading(today, today, english))
|
||||||
|
assertEquals("Yesterday", dayHeading(today.minusDays(1), today, english))
|
||||||
|
assertEquals("Monday", dayHeading(LocalDate.of(2026, 9, 28), today, english))
|
||||||
|
assertEquals("26 September", dayHeading(LocalDate.of(2026, 9, 26), today, english))
|
||||||
|
assertEquals("3 October 2025", dayHeading(LocalDate.of(2025, 10, 3), today, english))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LanguagePickerTest {
|
||||||
|
@Test
|
||||||
|
fun `a stored tag reads as its language in either ISO form`() {
|
||||||
|
assertEquals("Japanese", languageLabel("jpn"))
|
||||||
|
assertEquals("Japanese", languageLabel("ja"))
|
||||||
|
assertEquals("German", languageLabel("deu"))
|
||||||
|
assertEquals("German", languageLabel("ger"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a region suffix and capitals do not hide the language`() {
|
||||||
|
assertEquals("Portuguese", languageLabel("PT-br"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tag not on the list is shown as typed, and blank is no preference`() {
|
||||||
|
assertEquals("tgl", languageLabel("tgl"))
|
||||||
|
assertEquals("No preference", languageLabel(null))
|
||||||
|
assertEquals("No preference", languageLabel(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every listed language has a two-letter form`() {
|
||||||
|
LANGUAGES.forEach { (code, name) -> assertEquals(code, name, languageLabel(code)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.work.testing.WorkManagerTestInitHelper
|
||||||
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import io.narl.protonstream.ui.theme.ProtonFallback
|
||||||
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
import uniffi.pstr_android.ArrangementRecord
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.ShelfRecord
|
||||||
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.TileRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every screen, drawn from fixed records, against a committed image.
|
||||||
|
*
|
||||||
|
* The screens are about to be redesigned one by one, and a layout regression —
|
||||||
|
* a row that wraps, a button pushed off the edge — compiles and passes every
|
||||||
|
* other test. Here it is a changed PNG in review. No artwork is loaded: the
|
||||||
|
* bridge is not available on the host, so every tile shows its generated
|
||||||
|
* placeholder, which is also what a title without metadata looks like on a
|
||||||
|
* phone.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
|
||||||
|
class ScreenshotTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
||||||
|
// The palette is process-wide and the sandbox outlives a test, so a
|
||||||
|
// light capture would otherwise repaint every one after it.
|
||||||
|
AppearanceState.apply(ProtonFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLight(name: String, content: @Composable () -> Unit) {
|
||||||
|
AppearanceState.apply(LATTE)
|
||||||
|
capture(name, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
|
compose.setContent {
|
||||||
|
ProtonStreamTheme {
|
||||||
|
CompositionLocalProvider(LocalArtworkLoading provides false) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library on a phone`() = capture("library-phone") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title in the light palette`() = captureLight("title-phone-light") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `a title on a tablet`() = capture("title-tablet") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the history page on a tablet`() = capture("history-tablet") {
|
||||||
|
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||||
|
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||||
|
// reads it from, so the scale is set there.
|
||||||
|
val density = LocalDensity.current
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty library`() = capture("library-empty") {
|
||||||
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||||
|
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
|
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the playback settings`() = capture("settings-playback") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the storage settings`() = capture("settings-storage") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the history page`() = capture("history-phone") {
|
||||||
|
// Fixed day and zone: "Today" and "Yesterday" are relative, and an
|
||||||
|
// image that changes with the date is no baseline.
|
||||||
|
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the downloads page with a season saved`() = capture("downloads-phone") {
|
||||||
|
val saved = SERIES.seasons.first().episodes.take(2).map {
|
||||||
|
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
|
||||||
|
}
|
||||||
|
// One title: the fixtures reuse link ids across titles, so with the
|
||||||
|
// whole library the episodes would be filed under whichever came last.
|
||||||
|
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
|
||||||
|
val LATTE = PaletteRecord(
|
||||||
|
background = 0xFFEFF1F5u,
|
||||||
|
surface = 0xFFE6E9EFu,
|
||||||
|
sunken = 0xFFDCE0E8u,
|
||||||
|
card = 0xFFCCD0DAu,
|
||||||
|
cardHover = 0xFFBCC0CCu,
|
||||||
|
border = 0xFFBCC0CCu,
|
||||||
|
text = 0xFF4C4F69u,
|
||||||
|
muted = 0xFF6C6F85u,
|
||||||
|
accent = 0xFF732FCCu,
|
||||||
|
accentAlt = 0xFF1856D1u,
|
||||||
|
accentDim = 0xFFC4BBE4u,
|
||||||
|
onAccent = 0xFFEFF1F5u,
|
||||||
|
danger = 0xFFD20F39u,
|
||||||
|
elevated = 0xFFAAAEBDu,
|
||||||
|
dangerDim = 0xFFE7D1D5u,
|
||||||
|
light = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
||||||
|
// `detail` is the filename unless the filename named the episode,
|
||||||
|
// which is what the bridge sends.
|
||||||
|
val file = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt())
|
||||||
|
return episodeRecord(season, number, file, watched, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun episodeRecord(season: UInt, number: UInt, file: String, watched: Boolean, progress: Double?) = EpisodeRecord(
|
||||||
|
shareId = "share",
|
||||||
|
volumeId = "volume",
|
||||||
|
linkId = "s${season}e$number",
|
||||||
|
name = file,
|
||||||
|
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
|
||||||
|
detail = file,
|
||||||
|
season = season,
|
||||||
|
number = number,
|
||||||
|
size = 1_300_000_000uL,
|
||||||
|
progress = progress,
|
||||||
|
resumeAt = progress?.let { it * 1440 },
|
||||||
|
watched = watched,
|
||||||
|
offline = false,
|
||||||
|
providerName = if (number == 1u) "Mother and Children" else null,
|
||||||
|
providerOverview = null,
|
||||||
|
stillUrl = null,
|
||||||
|
airDate = null,
|
||||||
|
// 2026-09-21 for the part-watched one, the day before for the rest.
|
||||||
|
lastPlayed = when {
|
||||||
|
progress != null -> 1_790_000_000L
|
||||||
|
watched -> 1_789_900_000L - number.toLong() * 600
|
||||||
|
else -> 0L
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||||
|
key = key,
|
||||||
|
name = name,
|
||||||
|
year = 2023u,
|
||||||
|
kind = TitleType.SERIES,
|
||||||
|
watchedCount = seasons.sumOf { s -> s.episodes.count { it.watched } }.toULong(),
|
||||||
|
episodeCount = seasons.sumOf { it.episodes.size }.toULong(),
|
||||||
|
canonicalName = null,
|
||||||
|
originalName = null,
|
||||||
|
overview = overview,
|
||||||
|
metadataProvider = null,
|
||||||
|
metadataId = null,
|
||||||
|
metadataYear = null,
|
||||||
|
metadataKind = null,
|
||||||
|
posterUrl = null,
|
||||||
|
backdropUrl = null,
|
||||||
|
rating = 8.4,
|
||||||
|
genres = listOf("Drama", "Mystery"),
|
||||||
|
providerEpisodeCount = null,
|
||||||
|
externalUrl = null,
|
||||||
|
manualMatch = false,
|
||||||
|
seasons = seasons,
|
||||||
|
displayName = name,
|
||||||
|
wideUrl = null,
|
||||||
|
formatLabel = null,
|
||||||
|
seasonLabel = null,
|
||||||
|
studios = emptyList(),
|
||||||
|
directors = emptyList(),
|
||||||
|
tags = emptyList(),
|
||||||
|
airing = false,
|
||||||
|
nextEpisode = null,
|
||||||
|
nextAiringAt = null,
|
||||||
|
franchise = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SERIES = title(
|
||||||
|
"oshi-no-ko",
|
||||||
|
"Oshi no Ko",
|
||||||
|
listOf(
|
||||||
|
SeasonRecord(1u, "Season 1", (1u..4u).map { episode(1u, it, watched = it < 3u) }),
|
||||||
|
SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }),
|
||||||
|
),
|
||||||
|
overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.",
|
||||||
|
).copy(
|
||||||
|
formatLabel = "TV",
|
||||||
|
seasonLabel = "Spring 2023",
|
||||||
|
studios = listOf("Doga Kobo"),
|
||||||
|
directors = listOf("Daisuke Hiramaki"),
|
||||||
|
tags = listOf("Idol", "Reincarnation"),
|
||||||
|
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
|
||||||
|
val SEQUEL = title("oshi-no-ko-film", "Oshi no Ko: The Final Act", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
|
.copy(kind = TitleType.FILM, year = 2024u, formatLabel = "Film", franchise = SERIES.franchise)
|
||||||
|
|
||||||
|
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
|
.copy(kind = TitleType.FILM, year = 1988u)
|
||||||
|
|
||||||
|
val LIBRARY = AppUiState(
|
||||||
|
loading = false,
|
||||||
|
titles = listOf(
|
||||||
|
FILM,
|
||||||
|
SERIES,
|
||||||
|
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
|
||||||
|
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
|
||||||
|
),
|
||||||
|
// Bleach stands for one more title of its franchise, and one shelf
|
||||||
|
// sits above the grid: what the bridge's arrangement would give.
|
||||||
|
arrangement = ArrangementRecord(
|
||||||
|
tiles = listOf("akira", "ano-hana", "bleach", "oshi-no-ko").map {
|
||||||
|
TileRecord(it, if (it == "bleach") 1u else 0u)
|
||||||
|
},
|
||||||
|
shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))),
|
||||||
|
),
|
||||||
|
shares = listOf(
|
||||||
|
ShareRecord("anime", "anime", true),
|
||||||
|
ShareRecord("films", "films", false),
|
||||||
|
),
|
||||||
|
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 813 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 807 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 678 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -6,10 +6,15 @@ activity = "1.11.0"
|
|||||||
lifecycle = "2.9.4"
|
lifecycle = "2.9.4"
|
||||||
navigation = "2.9.5"
|
navigation = "2.9.5"
|
||||||
work = "2.10.5"
|
work = "2.10.5"
|
||||||
media3 = "1.8.0"
|
|
||||||
adaptive = "1.1.0"
|
adaptive = "1.1.0"
|
||||||
jna = "5.17.0"
|
jna = "5.17.0"
|
||||||
junit = "4.13.2"
|
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"
|
||||||
|
roborazzi = "1.76.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||||
@@ -18,8 +23,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
|
|||||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", 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-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||||
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
|
||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
@@ -29,11 +33,22 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
|||||||
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
|
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-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-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" }
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
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" }
|
||||||
|
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
|
||||||
|
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
|
||||||
|
roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||||
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
@@ -29,6 +29,7 @@ thiserror.workspace = true
|
|||||||
uniffi.workspace = true
|
uniffi.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
# Match rustls-platform-verifier's JNI types: Java objects from different
|
# Match rustls-platform-verifier's JNI types: Java objects from different
|
||||||
@@ -36,4 +37,3 @@ serde_json.workspace = true
|
|||||||
jni = "0.21.1"
|
jni = "0.21.1"
|
||||||
rustls-platform-verifier = "0.6.2"
|
rustls-platform-verifier = "0.6.2"
|
||||||
android_logger = "0.14"
|
android_logger = "0.14"
|
||||||
log = "0.4"
|
|
||||||
@@ -2,3 +2,9 @@
|
|||||||
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
# 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.
|
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||||
disable_java_cleaner = true
|
disable_java_cleaner = true
|
||||||
|
|
||||||
|
[bindings.swift]
|
||||||
|
# The iOS client (`ios/`) compiles the generated file straight into the app
|
||||||
|
# target and reaches the C side through its bridging header, so the names are
|
||||||
|
# what that header and `ios/project.yml` expect.
|
||||||
|
module_name = "PstrBridge"
|
||||||
@@ -24,8 +24,25 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
|
egui-phosphor.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
directories.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
souvlaki.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
skrifa.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
dbus.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
# Named pipes, for handing a second launch's request to the open window.
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
|
||||||
|
tauri-winrt-notification.workspace = true
|
||||||
|
raw-window-handle = "0.6"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -92,7 +92,12 @@ impl Harness {
|
|||||||
.context("eframe was built without a glow context")?;
|
.context("eframe was built without a glow context")?;
|
||||||
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
||||||
// context current, which is the same guarantee `App::ui` gives.
|
// context current, which is the same guarantee `App::ui` gives.
|
||||||
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
|
let video = unsafe {
|
||||||
|
VideoSurface::new(Arc::clone(&player), gl, {
|
||||||
|
let ctx = cc.egui_ctx.clone();
|
||||||
|
move || ctx.request_repaint()
|
||||||
|
})
|
||||||
|
}?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
player,
|
player,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Settings only the desktop app has.
|
||||||
|
//!
|
||||||
|
//! Kept out of [`pstr_core::prefs::PlaybackPrefs`] on purpose: that one is
|
||||||
|
//! shared with Android through a bridge record, and a field Android neither
|
||||||
|
//! shows nor keeps would be reset to its default every time Android saved.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the settings page offers for the streaming cache, in GiB.
|
||||||
|
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
// A file from an older version, or edited by hand, missing a field: the rest
|
||||||
|
// still counts, and the missing one takes its default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DesktopPrefs {
|
||||||
|
/// Let mpv decode on the GPU. Off is for a driver that shows green frames
|
||||||
|
/// or nothing; it costs CPU, not correctness.
|
||||||
|
pub hardware_decoding: bool,
|
||||||
|
/// How much decrypted video the disk cache may keep.
|
||||||
|
pub cache_gib: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DesktopPrefs {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hardware_decoding: true,
|
||||||
|
cache_gib: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopPrefs {
|
||||||
|
pub fn cache_bytes(&self) -> u64 {
|
||||||
|
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefs and where they live.
|
||||||
|
pub struct DesktopPrefsFile {
|
||||||
|
path: PathBuf,
|
||||||
|
/// False when the file was there and would not parse. Such a file is never
|
||||||
|
/// overwritten — it is far more likely a bug than something to replace —
|
||||||
|
/// so changes then last only for the session.
|
||||||
|
writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopPrefsFile {
|
||||||
|
pub fn load(path: PathBuf) -> (Self, DesktopPrefs) {
|
||||||
|
match pstr_core::config::read_json::<DesktopPrefs>(&path) {
|
||||||
|
Ok(prefs) => (
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
writable: true,
|
||||||
|
},
|
||||||
|
prefs.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("read the desktop settings: {error}");
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
DesktopPrefs::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, prefs: &DesktopPrefs) -> pstr_core::Result<()> {
|
||||||
|
if !self.writable {
|
||||||
|
tracing::warn!(
|
||||||
|
"not saving the desktop settings over {}, which would not parse",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
pstr_core::config::write_json(&self.path, prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_missing_a_field_keeps_the_rest() {
|
||||||
|
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
|
||||||
|
assert_eq!(prefs.cache_gib, 8);
|
||||||
|
assert!(prefs.hardware_decoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_file_is_never_overwritten() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||||
|
std::fs::write(&path, "{ not json").unwrap();
|
||||||
|
let (file, prefs) = DesktopPrefsFile::load(path.clone());
|
||||||
|
assert_eq!(prefs, DesktopPrefs::default());
|
||||||
|
file.save(&DesktopPrefs::default()).unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||