Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9925b4b09 | ||
|
|
fe69d2fae4 | ||
|
|
3fe4900337 | ||
|
|
973a8f2749 | ||
|
|
cf8ee58f6b | ||
|
|
466630a9ba | ||
|
|
a3bc9023c7 | ||
|
|
7bfebc5fd5 | ||
|
|
87ccaf26e1 | ||
|
|
fafe0c3087 | ||
|
|
f66faf6b75 | ||
|
|
cd9e8eea81 | ||
|
|
c026f81283 | ||
|
|
c6d303f269 | ||
|
|
255e463fc8 | ||
|
|
3ebfd1a57b | ||
|
|
f4c08d03c7 | ||
|
|
a7b87cdc79 | ||
|
|
70952fa7ca | ||
|
|
3ca431feca | ||
|
|
bb5a6638c4 | ||
|
|
b5ba59b41d | ||
|
|
79f0d74e1a | ||
|
|
648c35e92a | ||
|
|
ffd6e959bb | ||
|
|
69d961d7b8 | ||
|
|
238ecd8220 | ||
|
|
65a669e231 | ||
|
|
658c620c82 | ||
|
|
c1487c31d0 | ||
|
|
a8a18be248 | ||
|
|
16c2b4c9d3 | ||
|
|
2871a13ba6 | ||
|
|
6e0be9e3c8 | ||
|
|
0868c2af69 | ||
|
|
4a3a7291b8 | ||
|
|
ab7f057afe | ||
|
|
b1ea076ea3 | ||
|
|
8d615579e2 | ||
|
|
cafd969567 | ||
|
|
69981e3b45 | ||
|
|
cbe0f7166b | ||
|
|
f0402e74f9 | ||
|
|
d4e9628199 | ||
|
|
4b9cbd26bc | ||
|
|
588a7fcb09 | ||
|
|
e3e855a02d | ||
|
|
7790ae9cbb | ||
|
|
f0fe90f0fe | ||
|
|
51454b5a6e | ||
|
|
c44b04cf4a | ||
|
|
85bc975f5f | ||
|
|
4f130d1d6e | ||
|
|
fb3a97b408 | ||
|
|
04fc1f04fe | ||
|
|
aa0f4b1250 | ||
|
|
c261f88c3f | ||
|
|
bf3fa19c70 | ||
|
|
01eee78317 | ||
|
|
2546bab0a6 | ||
|
|
8d795c29e6 | ||
|
|
47c482498b | ||
|
|
31bdd75559 | ||
|
|
d43da778b6 | ||
|
|
abb3e3aaf8 | ||
|
|
5b931111b3 | ||
|
|
5c0519a226 | ||
|
|
5c15f45ace | ||
|
|
eb67df0aa0 | ||
|
|
fdf6b0359b | ||
|
|
6ddbcea429 | ||
|
|
a79e41aa8a | ||
|
|
89e55aac12 | ||
|
|
0f96cc4514 | ||
|
|
9d72acce88 | ||
|
|
d154002109 | ||
|
|
690455f7dd | ||
|
|
01c252b794 | ||
|
|
6d1943f2bd | ||
|
|
5dfb717235 | ||
|
|
3ff55a9af9 | ||
|
|
33da1563ee | ||
|
|
7994097a25 | ||
|
|
77a926cbf5 | ||
|
|
603cc0c1a9 | ||
|
|
5807493a4a | ||
|
|
a306d02cf1 | ||
|
|
c6b0457c4d | ||
|
|
c5490ab3da | ||
|
|
9e53a9bbdb | ||
|
|
ce02bbc35a | ||
|
|
0b9a9dab50 | ||
|
|
73973841c2 | ||
|
|
2f3be1d193 | ||
|
|
8e42906a7f | ||
|
|
dc70cafbb7 | ||
|
|
a28135cd51 | ||
|
|
4b9c01d52d | ||
|
|
3356602951 | ||
|
|
537217e70a | ||
|
|
ed05a1e723 | ||
|
|
da16ab0575 | ||
|
|
d9ca8f8d60 | ||
|
|
26cccd5c63 | ||
|
|
0b2813cd42 | ||
|
|
6cdab2fde1 | ||
|
|
6239780978 | ||
|
|
67cf3044f1 | ||
|
|
66be35c3d4 | ||
|
|
046cfe62b8 | ||
|
|
e3dcbec1f2 | ||
|
|
99abc8f205 | ||
|
|
3638e0e5df | ||
|
|
aef182e7e8 | ||
|
|
6853725ca0 | ||
|
|
4c80076762 | ||
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 | ||
|
|
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 |
No files matched your search
@@ -3,7 +3,15 @@ name: Android
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
paths:
|
||||
- 'android/**'
|
||||
@@ -31,8 +39,8 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
ANDROID_API: '36'
|
||||
ANDROID_BUILD_TOOLS: '37.0.0'
|
||||
ANDROID_NDK: '29.0.14206865'
|
||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||
ANDROID_NDK: '30.0.16248370'
|
||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/30.0.16248370
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -74,14 +82,23 @@ jobs:
|
||||
distribution: temurin
|
||||
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
|
||||
with:
|
||||
packages: platform-tools
|
||||
log-accepted-android-sdk-licenses: false
|
||||
|
||||
- name: Install native build prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends \
|
||||
autoconf automake build-essential cmake curl git gperf libtool \
|
||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||
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
|
||||
run: >-
|
||||
@@ -90,12 +107,53 @@ jobs:
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
"ndk;${ANDROID_NDK}"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
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
|
||||
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
|
||||
with:
|
||||
@@ -124,9 +182,15 @@ jobs:
|
||||
- name: Android checks and artifacts
|
||||
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:
|
||||
name: Signed release
|
||||
if: false
|
||||
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -141,14 +205,23 @@ jobs:
|
||||
distribution: temurin
|
||||
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
|
||||
with:
|
||||
packages: platform-tools
|
||||
log-accepted-android-sdk-licenses: false
|
||||
|
||||
- name: Install native build prerequisites
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends \
|
||||
autoconf automake build-essential cmake curl git gperf libtool \
|
||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||
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
|
||||
run: >-
|
||||
@@ -157,12 +230,53 @@ jobs:
|
||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||
"ndk;${ANDROID_NDK}"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
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
|
||||
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
|
||||
with:
|
||||
@@ -188,10 +302,30 @@ jobs:
|
||||
- name: Build signed APK and AAB
|
||||
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
|
||||
with:
|
||||
name: android-signed
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/bundle/release/*.aab
|
||||
name: android
|
||||
path: dist/android/*
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-bundle
|
||||
path: android/app/build/outputs/bundle/release/*.aab
|
||||
if-no-files-found: error
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
|
||||
# libmpv for pstr-player.
|
||||
- name: Install libmpv
|
||||
# libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
|
||||
- name: Install libmpv and WebKitGTK
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ios/**'
|
||||
- 'android/app/src/main/res/font/**'
|
||||
- 'android/app/src/main/assets/licenses/**'
|
||||
- 'crates/pstr-android/**'
|
||||
- 'crates/pstr-core/**'
|
||||
- 'crates/pstr-meta/**'
|
||||
- 'crates/pstr-stream/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'scripts/build-ios.sh'
|
||||
- '.github/workflows/ios.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Unit tests and unsigned .ipa
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||
|
||||
- name: Restore the Rust target tree
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: rust-target-ios-${{ steps.toolchain.outputs.cachekey }}-
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Bindings
|
||||
run: scripts/build-ios.sh bindings
|
||||
|
||||
- name: Unit tests on the simulator
|
||||
run: scripts/build-ios.sh test
|
||||
|
||||
# SideStore and AltStore re-sign whatever they install with the viewer's
|
||||
# own Apple ID, so the release artifact is unsigned.
|
||||
- name: Unsigned .ipa
|
||||
run: |
|
||||
scripts/build-ios.sh rust
|
||||
scripts/build-ios.sh ipa
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ios
|
||||
path: dist/*.ipa
|
||||
if-no-files-found: error
|
||||
@@ -20,9 +20,6 @@ permissions:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Android remains a workflow artifact only until the native libmpv adapter,
|
||||
# corresponding-source bundle, and on-device verification are complete.
|
||||
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
@@ -60,11 +57,6 @@ jobs:
|
||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
||||
|
||||
- name: Android publication remains blocked
|
||||
run: |
|
||||
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
|
||||
echo "Android APK/AAB publication is blocked until native playback is release-ready."
|
||||
|
||||
linux:
|
||||
name: Linux artifacts
|
||||
needs: verify
|
||||
@@ -84,7 +76,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev rpm
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
|
||||
|
||||
- name: Gate
|
||||
run: |
|
||||
@@ -148,25 +140,49 @@ jobs:
|
||||
dist/*.msi
|
||||
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
|
||||
|
||||
# The unsigned .ipa: SideStore and AltStore re-sign it with the viewer's own
|
||||
# Apple ID when they install it.
|
||||
ios:
|
||||
name: iOS
|
||||
needs: verify
|
||||
uses: ./.github/workflows/ios.yml
|
||||
|
||||
publish:
|
||||
name: Publish desktop artifacts
|
||||
name: Publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [linux, windows]
|
||||
needs: [linux, windows, android, ios]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
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
|
||||
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
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ios
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Refuse mobile artifacts while Android publishing is blocked
|
||||
run: |
|
||||
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
|
||||
echo "::error::Android publishing is disabled while native playback remains incomplete"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checksums
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Wiki
|
||||
|
||||
# docs/ is the source of the GitHub wiki. Every change to the documentation
|
||||
# checks its links; a push to main publishes the result, so the wiki follows
|
||||
# main. Pull requests only check: nothing unmerged reaches the wiki. Run it from
|
||||
# the Actions tab to publish another ref, such as a release tag.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check documentation links
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Check links
|
||||
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
|
||||
|
||||
publish:
|
||||
name: Publish to the wiki
|
||||
needs: check
|
||||
# Pull requests never publish.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: wiki-publish
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
|
||||
@@ -20,3 +20,9 @@
|
||||
/packaging/pkg/
|
||||
/packaging/src/
|
||||
/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
|
||||
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.1"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
opt-level = 2
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.5.1" }
|
||||
proton-drive-rs = { version = "0.5.1" }
|
||||
proton-sdk = { version = "0.9.0" }
|
||||
proton-drive-rs = { version = "0.9.0" }
|
||||
|
||||
# Internal crates.
|
||||
pstr-core = { path = "crates/pstr-core" }
|
||||
@@ -51,7 +51,11 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
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"
|
||||
# The id a watch-history file is named by. Already in the tree through the SDK.
|
||||
getrandom = "0.3"
|
||||
# `bundled` so a Windows build needs no system SQLite.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
# The share URL fragment and the custom password are both secrets; they go in
|
||||
@@ -69,14 +73,22 @@ keyring = { version = "3" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an
|
||||
# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context
|
||||
# eframe is drawing into.
|
||||
# eframe is drawing into. `accesskit` hands the widget tree to the platform's
|
||||
# screen reader (AT-SPI, UI Automation, NSAccessibility).
|
||||
eframe = { version = "0.35", default-features = false, features = [
|
||||
"accesskit",
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"wayland",
|
||||
"x11",
|
||||
] }
|
||||
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
|
||||
# pulls in every exotic format is weight this app has no use for.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
@@ -84,9 +96,28 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
||||
# behind a poisoned lock for the rest of the session. Same reason
|
||||
# `proton-drive-linux` uses it.
|
||||
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"
|
||||
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
|
||||
embed-resource = "3"
|
||||
# Proton's CAPTCHA page, which reports success by posting a message to its
|
||||
# host, so it needs a webview this app controls. Run in a child process of its
|
||||
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
|
||||
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
|
||||
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
|
||||
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
|
||||
rpassword = "7"
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# 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.
|
||||
|
||||
A source review on 2026-10-12 found these still short of the text below:
|
||||
|
||||
- **Title page.** The backdrop scrolls away with the back button instead of
|
||||
collapsing to a pinned bar, and the icon row has no mark-watched action.
|
||||
- **Episode rows** show the file size where runtime was asked for.
|
||||
- **Player episode list** is a bottom sheet in landscape too, not a side
|
||||
panel.
|
||||
- **Storage** has a usage bar for the streaming cache only; offline episodes
|
||||
have none.
|
||||
- **Metadata language** is still a free-text field.
|
||||
- **Touch targets.** The player's icons are 40 dp, under the 48 dp floor.
|
||||
- **Screenshots** do not cover the player, and only the library is taken at
|
||||
font scale 1.5.
|
||||
|
||||
Share rows now say how many files the last crawl found, when it ran and why it
|
||||
failed (section 2, Shares), as of the catalog's `share_crawls` table.
|
||||
|
||||
## 0. CI (done)
|
||||
|
||||
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||
`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.
|
||||
@@ -1,75 +1,145 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||
|
||||
# proton-stream
|
||||
|
||||
A Netflix-style cross-platform client for **Proton Drive public links**.
|
||||
**Your Proton Drive, streaming.** A Netflix-style library for the videos in your Proton Drive and
|
||||
the share links people send you, on Linux, Windows, Android, Android TV and iOS.
|
||||
|
||||
Paste a share URL and its password; get a browsable, streamable library. No
|
||||
Proton account, no server to host, no download step — episodes start playing in
|
||||
seconds and the seek bar works.
|
||||
**[proton-stream.narl.io](https://proton-stream.narl.io)**
|
||||
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust
|
||||
Proton Drive SDK.
|
||||
[](https://github.com/narrrl/proton-stream/actions/workflows/ci.yml)
|
||||
[](https://github.com/narrrl/proton-stream/releases/latest)
|
||||
[](#license)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/desktop-home.png" alt="The library in the desktop app" width="820">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||
> to describe what the software talks to. It provides no content: it plays what you keep in
|
||||
> Proton Drive or what is shared with you.
|
||||
|
||||
## Features
|
||||
|
||||
**Library**
|
||||
|
||||
- **Public share links or your own Drive.** Paste a share link, with its password if it has one,
|
||||
or sign in and pick folders of your Drive. Any number of them merge into one library.
|
||||
- **Seasons and episodes from your folders.** The folder names the show, the file name gives the
|
||||
season and episode, and a file without numbers is a film.
|
||||
- **Posters, synopses and shelves**, from AniList or TMDB, **off by default**: shelves such as
|
||||
*Directed by* and *From MADHOUSE*, a franchise folded into one tile, and a hand-picked match
|
||||
that is never overwritten.
|
||||
- **Filters and sorting:** series, films, watching, not started, downloaded; by name, last watched,
|
||||
last added, air date, rating or popularity.
|
||||
|
||||
**Player**
|
||||
|
||||
- **Streams, never downloads first.** Episodes start in seconds, and a seek fetches only what it
|
||||
lands on.
|
||||
- **libmpv inside the app's own window:** HEVC 10-bit, HDR and ASS subtitles with hardware
|
||||
decoding, and no transcoding.
|
||||
- **Skip the opening and the ending** by chapter, an **Up next** countdown, and next and previous
|
||||
episode.
|
||||
- **Subtitle files beside the video** (`.srt`, `.ass`, `.ssa`, `.vtt`), and **the dialogue track
|
||||
over *Signs & Songs***. Audio and subtitle choices are remembered for each show.
|
||||
|
||||
**Everywhere**
|
||||
|
||||
- **Resume on any device.** Sign in, and your place in every episode, your shares and your
|
||||
settings sync through your own Drive.
|
||||
- **Downloads** of an episode, a season or a show, to play offline.
|
||||
- **Android 12+ and Android TV** with Material 3, background audio and new-episode
|
||||
notifications; **iOS 17+** as a SwiftUI port for SideStore and AltStore.
|
||||
- **Themes:** a near-black default, four Catppuccin flavours and Persona 5, each with nine accents.
|
||||
- **Full keyboard control** on the desktop, and the `pstr` command line.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="images/desktop-library.png" alt="The whole library, filtered and sorted"></td>
|
||||
<td width="50%">
|
||||
<img src="images/android-home.png" alt="Android: home" width="24%">
|
||||
<img src="images/android-library.png" alt="Android: all titles" width="24%">
|
||||
<img src="images/android-title.png" alt="Android: a title page" width="24%">
|
||||
<img src="images/android-episodes.png" alt="Android: episodes and downloads" width="24%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Install
|
||||
|
||||
Every [release](https://github.com/narrrl/proton-stream/releases/latest) has:
|
||||
|
||||
| Platform | Download |
|
||||
|---|---|
|
||||
| Debian, Ubuntu | `proton-stream_<version>-1_amd64.deb` |
|
||||
| Fedora | `proton-stream-<version>-1.x86_64.rpm` |
|
||||
| Other Linux | `proton-stream-<version>-x86_64-unknown-linux-gnu.tar.gz` |
|
||||
| Windows | `proton-stream-<version>-x64.msi`, or the portable `…-x86_64-windows.zip` |
|
||||
| Android, Android TV | `proton-stream-<version>-android.apk` |
|
||||
| iOS, iPadOS | `proton-stream-<version>-ios.ipa`, for SideStore or AltStore |
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-stream_*_amd64.deb # Debian, Ubuntu
|
||||
sudo dnf install ./proton-stream-*.x86_64.rpm # Fedora
|
||||
cd packaging && makepkg -si # Arch, from a checkout
|
||||
```
|
||||
|
||||
Then open **Proton Stream**, paste a share link on the **Shares** page (or press **Ctrl+V**), and
|
||||
the library fills in. [Getting started](docs/GETTING_STARTED.md) walks through it, and
|
||||
[Installation](docs/INSTALL.md) covers every platform, updating and uninstalling.
|
||||
|
||||
## Documentation
|
||||
|
||||
The [wiki](https://github.com/narrrl/proton-stream/wiki) is generated from [`docs/`](docs/) on
|
||||
every push to `main`:
|
||||
|
||||
- [Getting started](docs/GETTING_STARTED.md), [Installation](docs/INSTALL.md) and the
|
||||
[FAQ](docs/FAQ.md)
|
||||
- [User guide](docs/USER_GUIDE.md), [Account and sync](docs/ACCOUNT_AND_SYNC.md) and
|
||||
[Android and iOS](docs/MOBILE.md)
|
||||
- [Command-line reference](docs/CLI.md), [Privacy](docs/PRIVACY.md) and
|
||||
[Troubleshooting](docs/TROUBLESHOOTING.md)
|
||||
- For contributors: [Development](docs/DEVELOPMENT.md), [Architecture](docs/ARCHITECTURE.md),
|
||||
[Testing](docs/TESTING.md), [Android](docs/ANDROID.md) and [iOS](docs/IOS.md)
|
||||
|
||||
## Why it works
|
||||
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet
|
||||
decrypted under one shared session key. There is no sequential streaming session
|
||||
to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode
|
||||
is playable without downloading 2 GB of it.
|
||||
A Proton Drive file's content blocks are each a self-contained PGP packet decrypted under one
|
||||
shared session key. There is no sequential streaming session to resume, so seeking costs exactly
|
||||
the blocks the seek lands on: a 2 GB episode plays without downloading 2 GB of it.
|
||||
|
||||
## Status
|
||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: **2.3 s to first
|
||||
frame, about 1.5 s to resume after dragging to an arbitrary point, and 61 MiB fetched for the
|
||||
first 38 seconds of playback.** Watch it again and the disk cache serves it with **no network at
|
||||
all**: 0.3 s to first frame.
|
||||
|
||||
Early, but it is an app now: the picture lands **inside the window**, not in an
|
||||
mpv window of its own. The SDK streaming surface, the catalog, the streaming
|
||||
layer and the player are done and verified against a real share, and the app has
|
||||
a library wall, a page per title with seasons and episodes,
|
||||
resume-where-you-left-off, an overlay with a chapter-marked seek bar, a skip
|
||||
button for openings and endings, next/previous episode with an "up next"
|
||||
countdown, optional metadata, themes and share management. What is left is mostly sidecar subtitle files
|
||||
(`.srt`/`.ass` beside the video are invisible to the crawl today) and running the
|
||||
macOS packaging on a real Mac. See `docs/DEVELOPMENT.md`.
|
||||
The player is fed through an in-process mpv stream callback, not a local HTTP server, so there is
|
||||
no port another process could ask for the plaintext. [Architecture](docs/ARCHITECTURE.md) has the
|
||||
whole data flow.
|
||||
|
||||
A native Android 12+ client is under `android/`, sharing the Rust catalog and
|
||||
streaming implementation through UniFFI. It features a modern Material 3 interface,
|
||||
an immersive fullscreen player with auto-hiding controls, and offline downloads.
|
||||
Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||
Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust Proton Drive
|
||||
SDK.
|
||||
|
||||
```bash
|
||||
cargo run --release -p pstr-app # the app
|
||||
```
|
||||
## Privacy
|
||||
|
||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||
is broken:
|
||||
- **No server, no telemetry.** Each app talks to Proton and to nothing else, unless you turn on
|
||||
posters and descriptions, which sends your title names to AniList or TMDB.
|
||||
- **The part of a share link after `#` is its decryption key.** It, any link password and your
|
||||
Proton session live in the system credential store (Secret Service, Credential Manager, the
|
||||
Android Keystore, the iOS Keychain), never in a config file.
|
||||
- **Sync is end-to-end encrypted.** Watch history, shares and settings travel through your own
|
||||
Drive, one file per device.
|
||||
- **The block cache and downloads hold decrypted video** in your cache and data folders. Size or
|
||||
empty the cache in **Settings → Storage**.
|
||||
|
||||
```bash
|
||||
cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment'
|
||||
cargo run -p pstr-cli -- crawl
|
||||
cargo run -p pstr-cli -- list
|
||||
cargo run -p pstr-cli -- metadata # opt-in; off unless configured
|
||||
cargo run --release -p pstr-cli -- play --file Frieren --disk-cache
|
||||
cargo run --release -p pstr-cli -- bench --file Frieren --verify
|
||||
```
|
||||
|
||||
On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link:
|
||||
**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point,
|
||||
61 MiB fetched for the first 38 seconds of playback.** Watch it again and the
|
||||
disk cache serves it with **no network at all** — 0.3 s to first frame.
|
||||
|
||||
`bench` measures the same file without a player, reporting cold seek latency,
|
||||
sustained throughput and peak memory, and `--verify` cross-checks a mid-file read
|
||||
against the SDK's own range download.
|
||||
|
||||
## Design
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Fat client. No server; each viewer's app talks to Proton directly. |
|
||||
| Auth | Public links only — but several, merged into one library. |
|
||||
| Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. |
|
||||
| Transcoding | None. mpv demuxes and decodes natively. |
|
||||
| UI | egui/eframe on glow; mpv renders into the same GL context. |
|
||||
| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. |
|
||||
| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. |
|
||||
| Platforms | Linux and Windows; 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. |
|
||||
[Privacy](docs/PRIVACY.md) lists every connection and every file.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -77,37 +147,26 @@ Rust 2024, MSRV 1.96.
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo run --release -p pstr-app # the app
|
||||
cargo run -p pstr-cli -- add --name anime 'URL' # the same work, headless
|
||||
```
|
||||
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those
|
||||
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||
crates.io; nothing here builds against a sibling checkout.
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux `pstr-app` needs
|
||||
WebKitGTK 4.1's, for the CAPTCHA window. The Proton SDK crates come from crates.io.
|
||||
|
||||
Installable artifacts — tarball, `.deb`, `.rpm`, Arch package, `.app`/`.dmg`,
|
||||
and a portable `.zip` plus an `.msi` on Windows — are produced by
|
||||
`scripts/build.sh` and `scripts\build.ps1`; `packaging/README.md` has the
|
||||
per-platform detail.
|
||||
|
||||
Android builds use JDK 17, Android SDK 36/NDK 28, Gradle 8.13, `cargo-ndk`, and
|
||||
UniFFI. Run `bash scripts/build-android.sh debug` after installing that toolchain;
|
||||
see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
||||
|
||||
## Privacy
|
||||
|
||||
- The share URL fragment **is** the decryption password. It and any custom
|
||||
password live in the OS credential store — Secret Service on Linux, Credential
|
||||
Manager on Windows — never in a config file.
|
||||
- Nothing leaves your machine except requests to Proton, unless you turn metadata
|
||||
enrichment on. The player is fed through an in-process mpv stream callback, not
|
||||
a local HTTP server: there is no port for another process on the machine to ask
|
||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
||||
would put a share secret in its log and window title.
|
||||
- The block cache holds decrypted content. It lives in your cache directory and
|
||||
can be deleted at any time the app is not running.
|
||||
- **Release artifacts:** `scripts/build.sh` (tarball, `.deb`, `.rpm`, Arch, macOS) and
|
||||
`scripts/build-windows.sh` or `scripts\build.ps1` (`.zip`, `.msi`). See
|
||||
[`packaging/README.md`](packaging/README.md).
|
||||
- **Android:** JDK 17, Android SDK 36, NDK 28, `cargo-ndk`, then
|
||||
`scripts/build-android.sh debug`. See [`docs/ANDROID.md`](docs/ANDROID.md).
|
||||
- **iOS:** macOS with Xcode and XcodeGen, then `scripts/build-ios.sh all`. See
|
||||
[`docs/IOS.md`](docs/IOS.md).
|
||||
|
||||
## License
|
||||
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||
application and its combined native distribution are GPL-3.0-or-later; see
|
||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||
The shared Rust crates and desktop application are MIT licensed. The Android and iOS applications
|
||||
and their combined native distributions are GPL-3.0-or-later; see
|
||||
[`android/LICENSE.md`](android/LICENSE.md), [`ios/LICENSE.md`](ios/LICENSE.md) and the bundled
|
||||
component notices. The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||
and the Phosphor icon font, under MIT.
|
||||
@@ -7,11 +7,12 @@ distributions.
|
||||
| Component | License |
|
||||
|---|---|
|
||||
| mpv/libmpv | GPL-2.0-or-later by default; verify the pinned build configuration |
|
||||
| LLVM libc++ shared runtime (Android NDK r29) | Apache-2.0 WITH LLVM-exception |
|
||||
| LLVM libc++ shared runtime (Android NDK r30) | Apache-2.0 WITH LLVM-exception |
|
||||
| AndroidX and Jetpack Compose | Apache-2.0 |
|
||||
| Kotlin | Apache-2.0 |
|
||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||
| UniFFI | MPL-2.0 |
|
||||
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||
| proton-stream shared Rust crates | MIT |
|
||||
|
||||
This summary is not a replacement for dependency license files. Before a
|
||||
|
||||
@@ -6,6 +6,7 @@ plugins {
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.roborazzi)
|
||||
}
|
||||
|
||||
val repositoryRoot = rootProject.projectDir.parentFile
|
||||
@@ -46,7 +47,7 @@ android {
|
||||
namespace = "io.narl.protonstream"
|
||||
compileSdk = 36
|
||||
buildToolsVersion = "37.0.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
ndkVersion = "30.0.16248370"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "io.narl.protonstream"
|
||||
@@ -142,6 +143,9 @@ android {
|
||||
"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
|
||||
}
|
||||
@@ -176,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
||||
val hostRustLibrary = repositoryRoot.resolve(
|
||||
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||
)
|
||||
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
||||
|
||||
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||
group = "native"
|
||||
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||
workingDir(repositoryRoot)
|
||||
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
||||
outputs.files(
|
||||
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
||||
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
||||
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
||||
stagedMpvDir.map { it.file("REVISION") },
|
||||
)
|
||||
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
|
||||
// deletes the declared outputs of a task it has no history for, which on a
|
||||
// fresh CI checkout threw away the tree just restored from the cache and
|
||||
// rebuilt libmpv from source on every run.
|
||||
}
|
||||
|
||||
val buildRustHost by tasks.registering(Exec::class) {
|
||||
@@ -269,6 +269,11 @@ dependencies {
|
||||
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)
|
||||
@@ -292,6 +297,13 @@ ktlint {
|
||||
}
|
||||
}
|
||||
|
||||
// Screenshot baselines live beside the tests that draw them, so a change to how
|
||||
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
|
||||
// rewrites them; `verifyRoborazziDebug` fails on any difference.
|
||||
roborazzi {
|
||||
outputDir.set(file("src/test/screenshots"))
|
||||
}
|
||||
|
||||
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||
// though the filter above drops every file in it. Without this, Gradle refuses
|
||||
// the build for an undeclared dependency between the two tasks.
|
||||
|
||||
@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
|
||||
*/
|
||||
@Test
|
||||
fun chapterPlanningCrossesTheBoundaryIntact() {
|
||||
val plan = chapterPlan(emptyList(), 1560.0)
|
||||
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
|
||||
assertNotNull(plan)
|
||||
assertNull(skipOffer(plan, 12.0))
|
||||
}
|
||||
|
||||
@@ -7,11 +7,21 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!--
|
||||
Android TV: listed in its launcher, and installable without a touch
|
||||
screen. Neither is required, so phones are offered the same build.
|
||||
-->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".ProtonStreamApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="33"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ProtonStream">
|
||||
@@ -29,14 +39,33 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/launcher_name"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| Kotlin | Apache-2.0 |
|
||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||
| UniFFI | MPL-2.0 |
|
||||
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||
| proton-stream reusable Rust crates | MIT |
|
||||
|
||||
Before release, replace this dependency summary with an inventory generated
|
||||
|
||||
@@ -27,10 +27,13 @@ extern "C" {
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
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 {
|
||||
uint64_t handle;
|
||||
@@ -191,13 +194,16 @@ struct PlaybackState {
|
||||
/// 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.
|
||||
/// Zero until then.
|
||||
double video_width = 0.0;
|
||||
double video_height = 0.0;
|
||||
/// Which loaded file this state describes. Bumped by every load, so a
|
||||
@@ -205,6 +211,17 @@ struct PlaybackState {
|
||||
/// episode one — which is the difference between resuming an episode where
|
||||
/// the viewer left it and resuming it where the *previous* one ended.
|
||||
int64_t generation = 0;
|
||||
/// mpv has loaded this generation's file and made its own track pick,
|
||||
/// which is the first moment the pick can be second-guessed.
|
||||
bool loaded = false;
|
||||
};
|
||||
|
||||
/// A subtitle file published for mpv, and what to call it.
|
||||
struct Subtitle {
|
||||
uint64_t handle = 0;
|
||||
std::string title;
|
||||
std::string language;
|
||||
bool select = false;
|
||||
};
|
||||
|
||||
class Player {
|
||||
@@ -216,6 +233,7 @@ class Player {
|
||||
option("vo", "libmpv");
|
||||
option("force-window", "no");
|
||||
option("video-timing-offset", "0");
|
||||
// The default; `load` sets it per file from the viewer's setting.
|
||||
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
||||
// 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,
|
||||
@@ -224,7 +242,7 @@ class Player {
|
||||
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
||||
// falling back. The copy variants decode in hardware and hand back
|
||||
// ordinary frames, which is what this render path can actually use.
|
||||
option("hwdec", "auto-copy-safe");
|
||||
option("hwdec", kHardwareDecoding);
|
||||
option("cache", "yes");
|
||||
option("cache-secs", "30");
|
||||
option("demuxer-readahead-secs", "30");
|
||||
@@ -245,6 +263,7 @@ class Player {
|
||||
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");
|
||||
@@ -294,14 +313,21 @@ class Player {
|
||||
}
|
||||
|
||||
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;
|
||||
// Per file, before `loadfile`: a device whose decoder shows green
|
||||
// frames is fixed from the next episode without restarting the core.
|
||||
set_string("hwdec", hardware_decoding ? kHardwareDecoding : "no");
|
||||
// No wait for a render context. It is created with the render thread and
|
||||
// needs no window, and a load that cannot get one plays audio anyway —
|
||||
// waiting for a surface here is what made background playback fail after
|
||||
// a five-second stall.
|
||||
if (!audio.empty()) set_string("alang", audio);
|
||||
if (!subtitle.empty()) set_string("slang", subtitle);
|
||||
//
|
||||
// Set even when empty: mpv keeps both across `loadfile`, so a file
|
||||
// with no language preference used to inherit the last one's.
|
||||
set_string("alang", audio);
|
||||
set_string("slang", subtitle);
|
||||
set_string("sid", subtitles ? "auto" : "no");
|
||||
pending_start_.store(std::max(0.0, start));
|
||||
// Retire the outgoing file's position, duration and end flag *before*
|
||||
@@ -327,7 +353,10 @@ class Player {
|
||||
state_.generation = generation;
|
||||
state_.loading = true;
|
||||
error_.clear();
|
||||
for (const auto &subtitle : pending_subtitles_) released_.push_back(subtitle.handle);
|
||||
pending_subtitles_.clear();
|
||||
}
|
||||
flush_released();
|
||||
const std::string url = "pstr://" + std::to_string(handle);
|
||||
const char *command[] = {"loadfile", url.c_str(), nullptr};
|
||||
if (mpv_command(mpv_, command) < 0) {
|
||||
@@ -361,6 +390,27 @@ class Player {
|
||||
mpv_command(mpv_, command);
|
||||
}
|
||||
|
||||
/// A subtitle file beside the file of load `generation`, added once mpv
|
||||
/// has that file loaded: an external track belongs to the file open at
|
||||
/// the time. Handed over for a file already replaced, it is released
|
||||
/// instead. `select` turns it on, but only when the file has no subtitle
|
||||
/// of its own playing.
|
||||
void add_subtitle(int64_t generation, Subtitle subtitle) {
|
||||
bool now = false;
|
||||
{
|
||||
std::lock_guard lock(state_mutex_);
|
||||
if (generation != state_.generation) {
|
||||
released_.push_back(subtitle.handle);
|
||||
} else if (loaded_generation_ == generation) {
|
||||
now = true;
|
||||
} else {
|
||||
pending_subtitles_.push_back(std::move(subtitle));
|
||||
}
|
||||
}
|
||||
flush_released();
|
||||
if (now) attach_subtitle(subtitle);
|
||||
}
|
||||
|
||||
PlaybackState state() const {
|
||||
std::lock_guard lock(state_mutex_);
|
||||
return state_;
|
||||
@@ -384,6 +434,8 @@ class Player {
|
||||
char *lang_raw = nullptr;
|
||||
char *title_raw = nullptr;
|
||||
int selected = 0;
|
||||
int is_default = 0;
|
||||
int forced = 0;
|
||||
mpv_get_property(mpv_, (base + "id").c_str(), MPV_FORMAT_INT64, &id);
|
||||
mpv_get_property(mpv_, (base + "type").c_str(), MPV_FORMAT_STRING, &type_raw);
|
||||
MpvString type(type_raw);
|
||||
@@ -392,10 +444,14 @@ class Player {
|
||||
mpv_get_property(mpv_, (base + "title").c_str(), MPV_FORMAT_STRING, &title_raw);
|
||||
MpvString title(title_raw);
|
||||
mpv_get_property(mpv_, (base + "selected").c_str(), MPV_FORMAT_FLAG, &selected);
|
||||
mpv_get_property(mpv_, (base + "default").c_str(), MPV_FORMAT_FLAG, &is_default);
|
||||
mpv_get_property(mpv_, (base + "forced").c_str(), MPV_FORMAT_FLAG, &forced);
|
||||
if (i) json += ',';
|
||||
json += "{\"id\":" + std::to_string(id) + ",\"type\":\"" + escape(type.get()) +
|
||||
"\",\"language\":\"" + escape(lang.get()) + "\",\"title\":\"" +
|
||||
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") + "}";
|
||||
escape(title.get()) + "\",\"selected\":" + (selected ? "true" : "false") +
|
||||
",\"default\":" + (is_default ? "true" : "false") +
|
||||
",\"forced\":" + (forced ? "true" : "false") + "}";
|
||||
}
|
||||
return json + ']';
|
||||
}
|
||||
@@ -423,6 +479,35 @@ class Player {
|
||||
}
|
||||
|
||||
private:
|
||||
void attach_subtitle(const Subtitle &subtitle) {
|
||||
bool showing = false;
|
||||
if (char *sid = mpv_get_property_string(mpv_, "sid")) {
|
||||
showing = std::strcmp(sid, "no") != 0;
|
||||
mpv_free(sid);
|
||||
}
|
||||
const std::string url = "pstr://" + std::to_string(subtitle.handle);
|
||||
std::vector<const char *> command{
|
||||
"sub-add", url.c_str(), subtitle.select && !showing ? "select" : "auto",
|
||||
subtitle.title.c_str()};
|
||||
if (!subtitle.language.empty()) command.push_back(subtitle.language.c_str());
|
||||
command.push_back(nullptr);
|
||||
// On failure mpv may never have opened it, so nothing will close it.
|
||||
// Releasing one it did open and close is a no-op.
|
||||
if (mpv_command(mpv_, command.data()) < 0) pstr_android_stream_release(subtitle.handle);
|
||||
}
|
||||
|
||||
/// Release the subtitle streams queued under `state_mutex_`, outside it:
|
||||
/// a release runs Rust destructors, which must not run under a lock a
|
||||
/// reader of the state may be waiting on.
|
||||
void flush_released() {
|
||||
std::vector<uint64_t> handles;
|
||||
{
|
||||
std::lock_guard lock(state_mutex_);
|
||||
handles.swap(released_);
|
||||
}
|
||||
for (const uint64_t handle : handles) pstr_android_stream_release(handle);
|
||||
}
|
||||
|
||||
struct MpvFree {
|
||||
void operator()(char *value) const noexcept { if (value) mpv_free(value); }
|
||||
};
|
||||
@@ -523,6 +608,17 @@ class Player {
|
||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||
const double start = pending_start_.exchange(0.0);
|
||||
if (start > 0.0) set_double("time-pos", start);
|
||||
std::vector<Subtitle> ready;
|
||||
{
|
||||
std::lock_guard lock(state_mutex_);
|
||||
// Mid-load, this is the outgoing file finishing its load.
|
||||
if (!state_.loading) {
|
||||
loaded_generation_ = state_.generation;
|
||||
state_.loaded = true;
|
||||
ready.swap(pending_subtitles_);
|
||||
}
|
||||
}
|
||||
for (const auto &subtitle : ready) attach_subtitle(subtitle);
|
||||
}
|
||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||
const auto *end = static_cast<mpv_event_end_file *>(event->data);
|
||||
@@ -591,6 +687,7 @@ class Player {
|
||||
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||
else if (!std::strcmp(property.name, "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 {
|
||||
@@ -745,6 +842,12 @@ class Player {
|
||||
bool surface_changed_ = false;
|
||||
std::atomic<bool> frame_ready_{false};
|
||||
std::atomic<double> pending_start_{0.0};
|
||||
/// The load generation whose file mpv has loaded, under `state_mutex_`.
|
||||
int64_t loaded_generation_ = -1;
|
||||
/// Subtitles handed over before their file loaded, under `state_mutex_`.
|
||||
std::vector<Subtitle> pending_subtitles_;
|
||||
/// Streams to release once `state_mutex_` is let go.
|
||||
std::vector<uint64_t> released_;
|
||||
};
|
||||
|
||||
Player *from(jlong handle) { return reinterpret_cast<Player *>(handle); }
|
||||
@@ -822,7 +925,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 {
|
||||
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, [=] {
|
||||
std::string audio_text;
|
||||
std::string subtitle_text;
|
||||
@@ -832,11 +935,23 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
||||
auto *p = from(handle);
|
||||
return static_cast<jboolean>(
|
||||
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_FALSE);
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeAddSubtitle(JNIEnv *env, jobject, jlong handle, jlong generation, jlong stream, jstring title, jstring language, jboolean select) noexcept {
|
||||
c_boundary([=] {
|
||||
Subtitle subtitle{static_cast<uint64_t>(stream), {}, {}, select == JNI_TRUE};
|
||||
auto *p = from(handle);
|
||||
if (!p || !utf8(env, title, subtitle.title) || !utf8(env, language, subtitle.language)) {
|
||||
pstr_android_stream_release(subtitle.handle);
|
||||
return;
|
||||
}
|
||||
p->add_subtitle(generation, std::move(subtitle));
|
||||
});
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativePause(JNIEnv *, jobject, jlong h, jboolean value) noexcept {
|
||||
c_boundary([=] { if (auto *p = from(h)) p->pause(value == JNI_TRUE); });
|
||||
}
|
||||
@@ -868,8 +983,9 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
||||
state.video_width, state.video_height,
|
||||
static_cast<jdouble>(state.end_reason),
|
||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||
state.seeking ? 1.0 : 0.0};
|
||||
constexpr jsize kFields = 13;
|
||||
state.seeking ? 1.0 : 0.0, state.cached_until,
|
||||
state.loaded ? 1.0 : 0.0};
|
||||
constexpr jsize kFields = 15;
|
||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||
|
||||
@@ -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,53 +1,41 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.NewEpisodesWorker
|
||||
import io.narl.protonstream.sync.WatchSyncWorker
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
private val inPictureInPicture = mutableStateOf(false)
|
||||
|
||||
/** 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.
|
||||
*
|
||||
@@ -65,30 +53,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport behind the buttons Android draws over the PiP window.
|
||||
*
|
||||
* A broadcast rather than a service intent: PiP is one of the states in
|
||||
* which a background service start is refused, and the receiver is
|
||||
* registered for as long as the activity that owns the window exists.
|
||||
*/
|
||||
private val pictureInPictureControls = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val host = playerHost.value ?: return
|
||||
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
||||
CONTROL_PLAY -> host.setPaused(false)
|
||||
CONTROL_PAUSE -> host.setPaused(true)
|
||||
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
||||
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationPermission = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before anything draws. The palette decides the window's own
|
||||
@@ -97,6 +65,9 @@ class MainActivity : ComponentActivity() {
|
||||
// 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()
|
||||
// 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
|
||||
@@ -109,32 +80,20 @@ class MainActivity : ComponentActivity() {
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
pictureInPictureControls,
|
||||
IntentFilter(ACTION_PIP_CONTROL),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
NewEpisodesWorker.schedule(this)
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
||||
// building the params inside onUserLeaveHint is too late for the swipe
|
||||
// home that gesture navigation sends, which never calls it. Re-register
|
||||
// whenever the picture's shape or the transport's does.
|
||||
lifecycleScope.launch {
|
||||
snapshotFlow { playerHost.value }
|
||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
||||
.map { state -> PictureInPictureShape.of(state) }
|
||||
.distinctUntilChanged()
|
||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
||||
}
|
||||
setContent {
|
||||
ProtonStreamTheme {
|
||||
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
||||
ProtonStreamApp(
|
||||
playerHost.value,
|
||||
shareLink = incomingShareLink.value,
|
||||
onShareLinkTaken = { incomingShareLink.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,27 +118,14 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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 onDestroy() {
|
||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
@@ -189,89 +135,14 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Where the viewer stopped is what their other devices want next, and
|
||||
// a process in the background may not live to the next timed sync.
|
||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!isInPictureInPictureMode &&
|
||||
!SettingsStore(this).backgroundAudio
|
||||
) {
|
||||
playerHost.value?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
||||
private data class PictureInPictureShape(
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
||||
active = state != null && state.duration > 0.0 && !state.ended,
|
||||
paused = state?.paused != false,
|
||||
width = state?.videoWidth?.toInt() ?: 0,
|
||||
height = state?.videoHeight?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
||||
PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio(width, height))
|
||||
.setAutoEnterEnabled(active)
|
||||
.setSeamlessResizeEnabled(false)
|
||||
// The player owns the whole window, so the picture's rectangle is
|
||||
// the window's. Without it the transition into PiP cross-fades from
|
||||
// a screenshot instead of shrinking the video that is already there.
|
||||
.setSourceRectHint(windowBounds())
|
||||
.setActions(
|
||||
listOf(
|
||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
||||
|
||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
||||
RemoteAction(
|
||||
Icon.createWithResource(this, icon),
|
||||
label,
|
||||
label,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
||||
const val EXTRA_CONTROL = "control"
|
||||
const val CONTROL_PLAY = "play"
|
||||
const val CONTROL_PAUSE = "pause"
|
||||
const val CONTROL_NEXT = "next"
|
||||
const val CONTROL_PREVIOUS = "previous"
|
||||
|
||||
/**
|
||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
||||
* and refuses the request outright rather than clamping it — so a
|
||||
* 2.40:1 scope release would take PiP away entirely.
|
||||
*/
|
||||
fun aspectRatio(width: Int, height: Int): Rational {
|
||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
||||
val ratio = width.toDouble() / height
|
||||
return when {
|
||||
ratio > MAX_RATIO -> Rational(239, 100)
|
||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
||||
else -> Rational(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
const val MAX_RATIO = 2.39
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,15 @@ package io.narl.protonstream
|
||||
|
||||
import android.app.Application
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
|
||||
class ProtonStreamApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NativeRuntime.initialize(this)
|
||||
// SharedPreferences reads its file on first use, and the first use was
|
||||
// on the main thread — in the playback service's start callback among
|
||||
// others. Read here, off it, and every later read is from memory.
|
||||
Thread({ SettingsStore.preload(this) }, "settings-preload").start()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ data class RetainedDownload(
|
||||
val total: Long = 0,
|
||||
val status: String = STATUS_QUEUED,
|
||||
val error: String? = null,
|
||||
/** Recent transfer rate while running; 0 when unknown or not running. */
|
||||
val bytesPerSecond: Long = 0,
|
||||
) {
|
||||
val key: String get() = "$shareId\u001f$linkId"
|
||||
|
||||
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
|
||||
.put("total", record.total)
|
||||
.put("status", record.status)
|
||||
.put("error", record.error)
|
||||
.put("rate", record.bytesPerSecond)
|
||||
.toString()
|
||||
|
||||
private fun decode(encoded: String): RetainedDownload {
|
||||
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
|
||||
total = json.optLong("total"),
|
||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||
error = json.optString("error").takeIf(String::isNotBlank),
|
||||
bytesPerSecond = json.optLong("rate"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
@@ -25,20 +29,39 @@ class OfflineDownloadWorker(
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(0))
|
||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
val store = DownloadStateStore(applicationContext)
|
||||
setForeground(createForegroundInfo(label, null))
|
||||
postSummary(store)
|
||||
try {
|
||||
return transfer(store, shareId, volumeId, linkId, label)
|
||||
} finally {
|
||||
postSummary(store)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun transfer(
|
||||
store: DownloadStateStore,
|
||||
shareId: String,
|
||||
volumeId: String,
|
||||
linkId: String,
|
||||
label: String,
|
||||
): Result {
|
||||
// Queued, not running: the engine lets two downloads transfer at once
|
||||
// and holds the rest back, and only the first progress report means
|
||||
// this one has a slot.
|
||||
var retained = store.get(shareId, linkId)
|
||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
|
||||
store.put(retained)
|
||||
|
||||
val observer = object : DownloadObserver {
|
||||
private var reportedAt = 0L
|
||||
private var reportedPercent = -1
|
||||
private val rate = TransferRate()
|
||||
|
||||
/**
|
||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||
@@ -51,13 +74,14 @@ class OfflineDownloadWorker(
|
||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val complete = total > 0UL && downloaded >= total
|
||||
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
reportedAt = now
|
||||
reportedPercent = percent
|
||||
setProgressAsync(workDataOf(KEY_DOWNLOADED to downloaded.toLong(), KEY_TOTAL to total.toLong()))
|
||||
setForegroundAsync(createForegroundInfo(percent))
|
||||
setForegroundAsync(createForegroundInfo(label, percent))
|
||||
// Under the store's lock: `pause()` is a concurrent writer, and
|
||||
// this read-modify-write used to put RUNNING back over the
|
||||
// viewer's PAUSED and then report the download as cancelled.
|
||||
@@ -66,6 +90,7 @@ class OfflineDownloadWorker(
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
bytesPerSecond = bytesPerSecond,
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
RetainedDownload.STATUS_CANCELLED,
|
||||
@@ -114,12 +139,20 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||
/**
|
||||
* This episode's line, under [GROUP]: a season is fourteen workers, and
|
||||
* fourteen loose notifications saying the same thing named nothing.
|
||||
* [progress] is a percentage, or null while waiting for a download slot.
|
||||
*/
|
||||
private fun createForegroundInfo(label: String, progress: Int?): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle("Making episode available offline")
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setContentTitle(label)
|
||||
.setContentText(if (progress == null) "Waiting to download" else "$progress %")
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.build()
|
||||
return ForegroundInfo(
|
||||
stableNotificationId(id),
|
||||
@@ -128,6 +161,36 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line the group folds into — "Downloading 12 episodes" — or none
|
||||
* once nothing is left to download. Posted as each worker starts and
|
||||
* ends rather than on progress, which each episode's own line carries.
|
||||
*/
|
||||
private fun postSummary(store: DownloadStateStore) {
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
val left = store.records().count {
|
||||
it.status == RetainedDownload.STATUS_QUEUED || it.status == RetainedDownload.STATUS_RUNNING
|
||||
}
|
||||
if (left == 0) {
|
||||
manager.cancel(SUMMARY_ID)
|
||||
return
|
||||
}
|
||||
val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!allowed) return
|
||||
manager.notify(
|
||||
SUMMARY_ID,
|
||||
NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(if (left == 1) "Downloading 1 episode" else "Downloading $left episodes")
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(GROUP)
|
||||
.setGroupSummary(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun error(message: String) = Data.Builder().putString(KEY_ERROR, message).build()
|
||||
|
||||
companion object {
|
||||
@@ -140,11 +203,45 @@ class OfflineDownloadWorker(
|
||||
const val KEY_TOTAL = "total"
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val CHANNEL_ID = "offline-downloads"
|
||||
private const val GROUP = "offline-downloads"
|
||||
|
||||
/** Below [stableNotificationId]'s range, so no episode's line replaces it. */
|
||||
internal const val SUMMARY_ID = 1
|
||||
|
||||
/** Shortest gap between two identical-looking progress reports. */
|
||||
private const val PROGRESS_INTERVAL_MS = 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes per second over roughly the last few seconds.
|
||||
*
|
||||
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||
* steps seconds apart; a rate beside it is what tells a slow download from a
|
||||
* stuck one. Smoothed, because one block that took a little longer than the
|
||||
* last would otherwise halve the figure for a moment.
|
||||
*/
|
||||
internal class TransferRate {
|
||||
private var lastBytes = -1L
|
||||
private var lastAt = 0L
|
||||
private var smoothed = 0.0
|
||||
|
||||
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
|
||||
fun sample(bytes: Long, now: Long): Long {
|
||||
if (lastBytes < 0 || bytes < lastBytes) {
|
||||
lastBytes = bytes
|
||||
lastAt = now
|
||||
return smoothed.toLong()
|
||||
}
|
||||
val elapsed = now - lastAt
|
||||
if (elapsed <= 0) return smoothed.toLong()
|
||||
val instant = (bytes - lastBytes) * 1000.0 / elapsed
|
||||
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
|
||||
lastBytes = bytes
|
||||
lastAt = now
|
||||
return smoothed.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun stableNotificationId(workId: UUID): Int =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(OfflineDownloadWorker.SUMMARY_ID + 1)
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -56,5 +57,9 @@ object NativeRuntime {
|
||||
}
|
||||
|
||||
private fun createEngine(): AndroidEngine =
|
||||
AndroidEngine(paths(), KeystoreSecretStore(appContext))
|
||||
AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply {
|
||||
// The budget is Android's setting, so the engine starts on the
|
||||
// default and is told the chosen one before anything streams.
|
||||
setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.Surface
|
||||
import io.narl.protonstream.ui.ThumbnailSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -19,7 +20,11 @@ import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import uniffi.pstr_android.ChapterPlan
|
||||
import uniffi.pstr_android.ChapterRecord
|
||||
import uniffi.pstr_android.SkipRangeRecord
|
||||
import uniffi.pstr_android.SubtitleFilesRecord
|
||||
import uniffi.pstr_android.SubtitleTrackRecord
|
||||
import uniffi.pstr_android.chapterPlan
|
||||
import uniffi.pstr_android.fullSubtitlesInstead
|
||||
|
||||
/**
|
||||
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
|
||||
@@ -75,6 +80,8 @@ data class MpvPlaybackState(
|
||||
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
|
||||
@@ -83,9 +90,9 @@ data class MpvPlaybackState(
|
||||
/**
|
||||
* 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.
|
||||
* The media session and its notification have to name the episode, and
|
||||
* neither can reach the composition that knows it. The player publishes this
|
||||
* once per episode and they read it.
|
||||
*/
|
||||
data class NowPlaying(
|
||||
val media: String,
|
||||
@@ -99,12 +106,25 @@ data class NowPlaying(
|
||||
val hasNext: Boolean,
|
||||
)
|
||||
|
||||
/** A sleep timer that is running. */
|
||||
sealed interface SleepTimer {
|
||||
/** Stop when [media] ends, rather than going on to the next episode. */
|
||||
data class EndOf(val media: String) : SleepTimer
|
||||
|
||||
/** Pause at [at], on the [SystemClock.elapsedRealtime] clock. */
|
||||
data class At(val at: Long) : SleepTimer
|
||||
}
|
||||
|
||||
data class MpvTrack(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val title: String,
|
||||
val selected: Boolean,
|
||||
/** The container marks it as the default choice. */
|
||||
val isDefault: Boolean = false,
|
||||
/** The container marks it as forced: on-screen text only, by convention. */
|
||||
val forced: Boolean = false,
|
||||
) {
|
||||
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||
@@ -121,19 +141,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
|
||||
private val mutableAdvancing = MutableStateFlow(false)
|
||||
private val mutableProblem = MutableStateFlow<String?>(null)
|
||||
private val mutableSleep = MutableStateFlow<SleepTimer?>(null)
|
||||
private var sleepJob: Job? = null
|
||||
private var poller: Job? = null
|
||||
/** The episode key and load generation the native player currently holds. */
|
||||
@Volatile private var currentMedia: String? = null
|
||||
@Volatile private var currentGeneration: Double = -1.0
|
||||
@Volatile private var plannedFor: Pair<Double, Long>? = null
|
||||
/** What AniSkip said about the open episode, keyed by that episode. */
|
||||
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
|
||||
/** The load generation whose subtitle pick has been looked at. */
|
||||
@Volatile private var subtitlesCheckedFor: Double = -1.0
|
||||
var onPlaybackStarted: (() -> Unit)? = null
|
||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||
var onExplicitStop: (() -> Unit)? = null
|
||||
/**
|
||||
* 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
|
||||
* The player owns which episode is open, so the notification and the lock
|
||||
* screen cannot walk the playlist themselves. Both
|
||||
* run on the main thread, which is where the player's state lives.
|
||||
*/
|
||||
var onSkipPrevious: (() -> Unit)? = null
|
||||
@@ -164,6 +190,25 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
*/
|
||||
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
|
||||
|
||||
/**
|
||||
* The sleep timer. Kept here rather than on the screen, because the
|
||||
* episode it is about keeps playing with the screen off.
|
||||
*/
|
||||
val sleep: StateFlow<SleepTimer?> = mutableSleep.asStateFlow()
|
||||
|
||||
/** Start a sleep timer, replacing any running one, or turn it off. */
|
||||
fun setSleepTimer(timer: SleepTimer?) {
|
||||
sleepJob?.cancel()
|
||||
mutableSleep.value = timer
|
||||
if (timer is SleepTimer.At) {
|
||||
sleepJob = scope.launch {
|
||||
delay((timer.at - SystemClock.elapsedRealtime()).coerceAtLeast(0))
|
||||
mutableSleep.value = null
|
||||
setPaused(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the current [problem]. */
|
||||
fun clearProblem() { mutableProblem.value = null }
|
||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||
@@ -184,12 +229,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
audioLanguage: String?,
|
||||
subtitleLanguage: String?,
|
||||
subtitles: Boolean,
|
||||
hardwareDecoding: Boolean,
|
||||
) {
|
||||
check(size > 0uL) { "Cannot play an empty stream" }
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val loaded = withOpenHandle {
|
||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
||||
nativeLoad(
|
||||
it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles,
|
||||
hardwareDecoding,
|
||||
).also { loaded ->
|
||||
if (loaded) {
|
||||
// The outgoing stream is released by mpv closing it,
|
||||
// not from here: `loadfile` is asynchronous and its
|
||||
@@ -211,6 +260,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
mutableTracks.value = emptyList()
|
||||
mutableChapters.value = ChapterPlan(emptyList(), null)
|
||||
plannedFor = null
|
||||
skipRanges = null
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
@@ -239,7 +289,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
*/
|
||||
fun beginTransition() { mutableAdvancing.value = true }
|
||||
|
||||
/** Names the open episode for the media session, its notification and PiP. */
|
||||
/** Names the open episode for the media session and its notification. */
|
||||
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
@@ -253,6 +303,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
}
|
||||
|
||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||
|
||||
/**
|
||||
* Take AniSkip's answer for [media], and plan the chapters again with it.
|
||||
* Dropped when another episode has opened since it was asked for.
|
||||
*/
|
||||
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
|
||||
if (media != currentMedia) return
|
||||
skipRanges = media to ranges
|
||||
plannedFor = null
|
||||
refreshChapters(mutableState.value)
|
||||
}
|
||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||
|
||||
@@ -261,7 +322,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
fun selectTrack(track: MpvTrack?) {
|
||||
val audio = track?.type == "audio"
|
||||
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
|
||||
refreshTracks()
|
||||
// mpv makes the switch on its own thread and lists it a moment later,
|
||||
// so a track list read back now still ticks the old track. The tick
|
||||
// moves here, and a read shortly after confirms it.
|
||||
val type = if (audio) "audio" else "sub"
|
||||
mutableTracks.value = mutableTracks.value.map {
|
||||
if (it.type == type) it.copy(selected = it.id == track?.id) else it
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
delay(TRACK_SETTLE_MS)
|
||||
refreshTracks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,6 +357,30 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
|
||||
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
|
||||
|
||||
/**
|
||||
* Hand mpv the subtitle files beside [media], the episode [play] loaded.
|
||||
*
|
||||
* Native code holds them until mpv has the file loaded, since an external
|
||||
* track belongs to the file open at the time, and releases them instead
|
||||
* if another episode has been loaded since. [SubtitleFilesRecord.preferred]
|
||||
* is turned on only when the file has no subtitle of its own playing;
|
||||
* pass [subtitles] false to turn none on.
|
||||
*/
|
||||
fun addSubtitles(media: String, files: SubtitleFilesRecord, subtitles: Boolean) {
|
||||
val generation = currentGeneration.toLong()
|
||||
val added = withOpenHandle { handle ->
|
||||
files.files.forEachIndexed { index, file ->
|
||||
if (currentMedia == media) {
|
||||
val select = subtitles && files.preferred == index.toUInt()
|
||||
nativeAddSubtitle(handle, generation, file.handle.toLong(), file.title, file.language, select)
|
||||
} else {
|
||||
pstrAndroidStreamRelease(file.handle.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added == null) files.files.forEach { pstrAndroidStreamRelease(it.handle.toLong()) }
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
if (poller?.isActive == true) return
|
||||
// Off the main thread deliberately: `nativeState` and `nativeTracks`
|
||||
@@ -309,12 +404,19 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
buffering = values[BUFFERING] != 0.0,
|
||||
cachePercent = values[CACHE_PERCENT],
|
||||
seeking = values[SEEKING] != 0.0,
|
||||
cachedUntil = values[CACHED_UNTIL],
|
||||
)
|
||||
mutableState.value = next
|
||||
// The listeners drive the media session and the player UI,
|
||||
// both of which are the main thread's.
|
||||
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
|
||||
refreshChapters(next)
|
||||
if (values[LOADED] != 0.0 && values.generation() == currentGeneration &&
|
||||
subtitlesCheckedFor != currentGeneration
|
||||
) {
|
||||
subtitlesCheckedFor = currentGeneration
|
||||
preferFullSubtitles()
|
||||
}
|
||||
}
|
||||
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
|
||||
if (tick++ % 4 == 0) refreshTracks()
|
||||
@@ -348,17 +450,37 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
}
|
||||
}.onSuccess { chapters ->
|
||||
plannedFor = wanted
|
||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
|
||||
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
|
||||
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the dialogue rather than the signs and songs mpv picked by language
|
||||
* (`pstr_core::subtitles`). Not remembered: it is the choice mpv should
|
||||
* have made, not the viewer's.
|
||||
*/
|
||||
private fun preferFullSubtitles() {
|
||||
refreshTracks()
|
||||
val subtitles = mutableTracks.value.filter { it.type == "sub" }
|
||||
val id = fullSubtitlesInstead(
|
||||
subtitles.map {
|
||||
SubtitleTrackRecord(it.id, it.language, it.title, it.isDefault, it.forced, it.selected)
|
||||
},
|
||||
) ?: return
|
||||
subtitles.firstOrNull { it.id == id }?.let(::selectTrack)
|
||||
}
|
||||
|
||||
private fun refreshTracks() {
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||
val values = JSONArray(encoded)
|
||||
List(values.length()) { index ->
|
||||
values.getJSONObject(index).run {
|
||||
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
||||
MpvTrack(
|
||||
getLong("id"), getString("type"), getString("language"), getString("title"),
|
||||
getBoolean("selected"), optBoolean("default"), optBoolean("forced"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onSuccess { mutableTracks.value = it }
|
||||
@@ -398,7 +520,23 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
private external fun nativeDestroy(handle: Long)
|
||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||
private external fun nativeDetachSurface(handle: Long)
|
||||
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
|
||||
private external fun nativeLoad(
|
||||
handle: Long,
|
||||
stream: Long,
|
||||
start: Double,
|
||||
audio: String?,
|
||||
subtitle: String?,
|
||||
subtitles: Boolean,
|
||||
hardwareDecoding: Boolean,
|
||||
): Boolean
|
||||
private external fun nativeAddSubtitle(
|
||||
handle: Long,
|
||||
generation: Long,
|
||||
stream: Long,
|
||||
title: String,
|
||||
language: String?,
|
||||
select: Boolean,
|
||||
)
|
||||
private external fun nativePause(handle: Long, paused: Boolean)
|
||||
private external fun nativeSeek(handle: Long, position: Double)
|
||||
private external fun nativeVolume(handle: Long, volume: Double)
|
||||
@@ -419,7 +557,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
|
||||
companion object {
|
||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||
private const val FIELDS = 13
|
||||
private const val FIELDS = 15
|
||||
private const val GENERATION = 6
|
||||
private const val WIDTH = 7
|
||||
private const val HEIGHT = 8
|
||||
@@ -427,6 +565,11 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
||||
private const val BUFFERING = 10
|
||||
private const val CACHE_PERCENT = 11
|
||||
private const val SEEKING = 12
|
||||
private const val CACHED_UNTIL = 13
|
||||
private const val LOADED = 14
|
||||
|
||||
/** How long after a track switch mpv's track list is read again. */
|
||||
private const val TRACK_SETTLE_MS = 300L
|
||||
|
||||
init {
|
||||
System.loadLibrary("pstr_android")
|
||||
|
||||
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.loadArtwork
|
||||
import io.narl.protonstream.ui.loadThumbnail
|
||||
@@ -33,9 +35,11 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||
class PlaybackService : Service() {
|
||||
inner class PlaybackBinder : Binder() {
|
||||
val host: NativeMpvHost? get() = this@PlaybackService.host
|
||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
||||
private var notified: NotificationShape? = null
|
||||
/** The pending teardown, held so an episode transition can call it off. */
|
||||
private var retire: Job? = null
|
||||
/** The open episode's latest reading, which [track] saves. */
|
||||
private var lastReading: MpvPlaybackState? = null
|
||||
private var lastSave = 0L
|
||||
/** The episode's artwork, and the source key it was fetched for. */
|
||||
private var artwork: Pair<String, Bitmap>? = null
|
||||
private val binder = PlaybackBinder()
|
||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it) }
|
||||
onStateChanged = { updateSession(it); track(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Swiped away from Recents. With background audio on, this service would
|
||||
* otherwise play on with no screen to return to and no autoplay, so it
|
||||
* saves where the episode got to and stops.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
lastReading?.let { reading ->
|
||||
// Bounded and on this thread: the process may go as soon as the
|
||||
// service stops, and a save launched into its scope would go with it.
|
||||
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||
}
|
||||
host?.stop()
|
||||
retirePlayback()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
runCatching { unregisterReceiver(becomingNoisy) }
|
||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the resume position from here rather than from the player screen.
|
||||
*
|
||||
* This service outlives the activity whenever background audio is on, and a
|
||||
* position the screen alone kept stopped being saved the moment the screen
|
||||
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||
* change saves the outgoing episode's last reading before the new one's.
|
||||
*/
|
||||
private fun track(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
if (state.duration <= 0.0) return
|
||||
val previous = lastReading
|
||||
lastReading = state
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val due = when {
|
||||
previous != null && previous.media != media -> {
|
||||
scope.launch(Dispatchers.IO) { save(previous) }
|
||||
true
|
||||
}
|
||||
state.ended -> previous?.ended != true
|
||||
state.paused -> previous?.paused != true
|
||||
else -> now - lastSave >= SAVE_EVERY_MS
|
||||
}
|
||||
if (due) {
|
||||
lastSave = now
|
||||
scope.launch(Dispatchers.IO) { save(state) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(state: MpvPlaybackState) {
|
||||
val media = state.media ?: return
|
||||
// Played to the end, or near enough that what is left is credits. A
|
||||
// failure or a stop also "ends", and recording either as watched would
|
||||
// retire an episode nobody saw.
|
||||
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||
runCatching {
|
||||
NativeRuntime.engine().saveWatchState(
|
||||
media.substringBeforeLast(':'),
|
||||
media.substringAfterLast(':'),
|
||||
state.position.coerceIn(0.0, state.duration),
|
||||
state.duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
refreshSession(state, host?.nowPlaying?.value)
|
||||
if (state.ended) scheduleRetire() else cancelRetire()
|
||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
||||
private const val TRANSITION_GRACE_MS = 30_000L
|
||||
/** How far the volume drops for a navigation prompt or a notification. */
|
||||
private const val DUCK_FACTOR = 0.3
|
||||
private const val SAVE_EVERY_MS = 5_000L
|
||||
/** How long a swipe from Recents may hold the main thread to save. */
|
||||
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||
|
||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.BrightnessMedium
|
||||
import androidx.compose.material.icons.filled.FastForward
|
||||
import androidx.compose.material.icons.filled.FastRewind
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What a swipe on the picture is doing, for the bubble that says so. */
|
||||
sealed interface Swipe {
|
||||
/** The window's brightness, from 0 to 1. */
|
||||
data class Brightness(val level: Float) : Swipe
|
||||
|
||||
/** The device's media volume, from 0 to 1. */
|
||||
data class Volume(val level: Float) : Swipe
|
||||
|
||||
/** Where the playhead will land when the finger lifts, and where it was. */
|
||||
data class Seek(val from: Double, val to: Double) : Swipe
|
||||
}
|
||||
|
||||
/** How much of the file one swipe across the whole width is worth, in seconds. */
|
||||
private const val SEEK_SPAN_SECONDS = 90.0
|
||||
|
||||
/**
|
||||
* A finger that comes down this close to the top or bottom of the screen is
|
||||
* the system's: pulling down the shade, swiping home.
|
||||
*/
|
||||
private val SYSTEM_EDGE = 48.dp
|
||||
|
||||
/** Which way a swipe went, settled by its first movement past the touch slop. */
|
||||
private enum class Axis { Brightness, Volume, Seek, Ignored }
|
||||
|
||||
/**
|
||||
* The swipes every phone video player has taught: up and down on the left for
|
||||
* brightness, on the right for volume, and sideways to seek — shown while the
|
||||
* finger is down and only seeking when it lifts, because a seek per movement
|
||||
* would throw away every block in flight.
|
||||
*/
|
||||
fun Modifier.playerSwipes(
|
||||
enabled: Boolean,
|
||||
activity: Activity?,
|
||||
position: () -> Double,
|
||||
duration: () -> Double,
|
||||
onSwipe: (Swipe?) -> Unit,
|
||||
onSeek: (Double) -> Unit,
|
||||
): Modifier = pointerInput(enabled, activity) {
|
||||
if (!enabled) return@pointerInput
|
||||
val audio = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val edge = SYSTEM_EDGE.toPx()
|
||||
var axis = Axis.Ignored
|
||||
var start = Offset.Zero
|
||||
var travelled = Offset.Zero
|
||||
var level = 0f
|
||||
var seekFrom = 0.0
|
||||
var seekTo = 0.0
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
start = offset
|
||||
travelled = Offset.Zero
|
||||
axis = if (offset.y < edge || offset.y > size.height - edge) Axis.Ignored else Axis.Seek
|
||||
},
|
||||
onDragEnd = {
|
||||
if (axis == Axis.Seek && seekTo != seekFrom) onSeek(seekTo)
|
||||
onSwipe(null)
|
||||
},
|
||||
onDragCancel = { onSwipe(null) },
|
||||
) { change, amount ->
|
||||
if (axis == Axis.Ignored) return@detectDragGestures
|
||||
change.consume()
|
||||
val first = travelled == Offset.Zero
|
||||
travelled += amount
|
||||
if (first) {
|
||||
// The first movement past the slop decides what the swipe is for.
|
||||
axis = when {
|
||||
abs(amount.x) > abs(amount.y) -> Axis.Seek
|
||||
start.x < size.width / 2 -> Axis.Brightness
|
||||
else -> Axis.Volume
|
||||
}
|
||||
level = when (axis) {
|
||||
Axis.Brightness -> activity?.brightness() ?: 0.5f
|
||||
Axis.Volume -> audio?.mediaLevel() ?: 0f
|
||||
else -> 0f
|
||||
}
|
||||
seekFrom = position()
|
||||
seekTo = seekFrom
|
||||
}
|
||||
when (axis) {
|
||||
// A swipe the whole height of the screen goes from none to all.
|
||||
Axis.Brightness -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
activity?.setBrightness(level)
|
||||
onSwipe(Swipe.Brightness(level))
|
||||
}
|
||||
Axis.Volume -> {
|
||||
level = (level - amount.y / size.height).coerceIn(0f, 1f)
|
||||
audio?.setMediaLevel(level)
|
||||
onSwipe(Swipe.Volume(level))
|
||||
}
|
||||
Axis.Seek -> {
|
||||
val length = duration()
|
||||
if (length <= 0.0) return@detectDragGestures
|
||||
seekTo = (seekFrom + travelled.x / size.width * SEEK_SPAN_SECONDS).coerceIn(0.0, length)
|
||||
onSwipe(Swipe.Seek(seekFrom, seekTo))
|
||||
}
|
||||
Axis.Ignored -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The window's own brightness, or the screen's when the window has none. */
|
||||
private fun Activity.brightness(): Float {
|
||||
val own = window.attributes.screenBrightness
|
||||
if (own >= 0f) return own
|
||||
return runCatching {
|
||||
android.provider.Settings.System.getInt(contentResolver, android.provider.Settings.System.SCREEN_BRIGHTNESS) / 255f
|
||||
}.getOrDefault(0.5f)
|
||||
}
|
||||
|
||||
private fun Activity.setBrightness(level: Float) {
|
||||
window.attributes = window.attributes.apply {
|
||||
// Never quite off: a black screen under a playing film looks broken.
|
||||
screenBrightness = level.coerceAtLeast(0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand brightness back to the system, as the player is left. */
|
||||
fun Activity.releaseBrightness() {
|
||||
window.attributes = window.attributes.apply {
|
||||
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioManager.mediaLevel(): Float {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1)
|
||||
return getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / max
|
||||
}
|
||||
|
||||
private fun AudioManager.setMediaLevel(level: Float) {
|
||||
val max = getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
// No flags: the bubble is the feedback, and the system's own volume panel
|
||||
// over the picture would be a second one.
|
||||
setStreamVolume(AudioManager.STREAM_MUSIC, (level * max).roundToInt(), 0)
|
||||
}
|
||||
|
||||
/** The bubble in the middle of the picture while a swipe is under way. */
|
||||
@Composable
|
||||
fun SwipeBubble(swipe: Swipe, format: (Double) -> String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (swipe) {
|
||||
is Swipe.Brightness -> Level(Icons.Default.BrightnessMedium, "Brightness", swipe.level)
|
||||
is Swipe.Volume -> Level(Icons.AutoMirrored.Filled.VolumeUp, "Volume", swipe.level)
|
||||
is Swipe.Seek -> {
|
||||
val delta = swipe.to - swipe.from
|
||||
Icon(
|
||||
if (delta < 0) Icons.Default.FastRewind else Icons.Default.FastForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
(if (delta < 0) "−" else "+") + format(abs(delta)),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(format(swipe.to), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Level(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, level: Float) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||
Text("$label ${(level * 100).roundToInt()}%", color = Color.White, style = MaterialTheme.typography.labelLarge)
|
||||
Box(Modifier.width(120.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { level },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = Color.White.copy(alpha = 0.25f),
|
||||
drawStopIndicator = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ interface LibmpvHost {
|
||||
audioLanguage: String? = null,
|
||||
subtitleLanguage: String? = null,
|
||||
subtitles: Boolean = true,
|
||||
hardwareDecoding: Boolean = true,
|
||||
)
|
||||
|
||||
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.narl.protonstream.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* The settings that are Android's alone.
|
||||
@@ -20,9 +21,69 @@ class SettingsStore(context: Context) {
|
||||
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
||||
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
||||
|
||||
private companion object {
|
||||
const val NAME = "proton_stream_settings"
|
||||
const val KEY_WIFI_ONLY = "wifi_only"
|
||||
const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||
/**
|
||||
* Whether mpv may decode in hardware. Off is the way out for a device whose
|
||||
* MediaCodec decoder shows green or torn frames; it costs battery, so it is
|
||||
* 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() }
|
||||
|
||||
/** How the library was last sorted, as a `LibrarySort` name; null for the default. */
|
||||
var librarySort: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_SORT, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
|
||||
|
||||
/** The library's offer of posters and synopses was turned down. */
|
||||
var metadataOfferDismissed: Boolean
|
||||
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
|
||||
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
|
||||
|
||||
/** Which titles the library last showed, as a `LibraryFilter` name; null for all. */
|
||||
var libraryFilter: String?
|
||||
get() = preferences.getString(KEY_LIBRARY_FILTER, null)
|
||||
set(value) { preferences.edit { putString(KEY_LIBRARY_FILTER, value) } }
|
||||
|
||||
/** Whether the library folds each franchise into one tile. */
|
||||
var libraryGrouped: Boolean
|
||||
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
|
||||
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
|
||||
|
||||
/** Whether a background crawl may say that a show gained episodes. */
|
||||
var newEpisodeNotifications: Boolean
|
||||
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
|
||||
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
|
||||
|
||||
companion object {
|
||||
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||
|
||||
/** `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
|
||||
|
||||
/** Load the file into memory, so no later read waits on the disk. */
|
||||
fun preload(context: Context) {
|
||||
context.getSharedPreferences(NAME, Context.MODE_PRIVATE).all
|
||||
}
|
||||
|
||||
private const val NAME = "proton_stream_settings"
|
||||
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||
private const val KEY_LIBRARY_SORT = "library_sort"
|
||||
private const val KEY_LIBRARY_GROUPED = "library_grouped"
|
||||
private const val KEY_LIBRARY_FILTER = "library_filter"
|
||||
private const val KEY_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
|
||||
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package io.narl.protonstream.sync
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.R
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import uniffi.pstr_android.NewEpisodesRecord
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Look at the shares while the app is away, and say when a show gained episodes.
|
||||
*
|
||||
* The app crawls a share it has not looked at for six hours whenever it opens,
|
||||
* which finds a new episode only once someone opens it to look. This runs the
|
||||
* same crawl on WorkManager's schedule instead. It only reports files the
|
||||
* crawl itself found, so a share the app crawled since says nothing twice.
|
||||
*/
|
||||
class NewEpisodesWorker(
|
||||
appContext: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
|
||||
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
|
||||
if (news.isNotEmpty()) post(news)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun post(news: List<NewEpisodesRecord>) {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = "A show in your library gained episodes"
|
||||
},
|
||||
)
|
||||
val open = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
Intent(applicationContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val compat = NotificationManagerCompat.from(applicationContext)
|
||||
// One per show, keyed by it, so a second crawl's news for the same show
|
||||
// replaces the first rather than stacking under it.
|
||||
news.forEach { item ->
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle(item.title)
|
||||
.setContentText(describe(item))
|
||||
.setContentIntent(open)
|
||||
.setGroup(GROUP)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
|
||||
}
|
||||
if (news.size > 1) {
|
||||
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification_stream)
|
||||
.setContentTitle("New episodes")
|
||||
.setContentText(news.joinToString { it.title })
|
||||
.setContentIntent(open)
|
||||
.setGroup(GROUP)
|
||||
.setGroupSummary(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "new-episodes"
|
||||
private const val CHANNEL_ID = "new-episodes"
|
||||
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
|
||||
private const val SUMMARY_TAG = "new-episodes-summary"
|
||||
private const val NOTIFICATION_ID = 7
|
||||
private const val INTERVAL_HOURS = 6L
|
||||
|
||||
/** What one show's notification says: "S01E04 and 2 more". */
|
||||
fun describe(news: NewEpisodesRecord): String {
|
||||
val more = news.count.toInt() - 1
|
||||
val first = news.first
|
||||
return when {
|
||||
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
|
||||
more == 0 -> "$first is new"
|
||||
more == 1 -> "$first and 1 more are new"
|
||||
else -> "$first and $more more are new"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the periodic look scheduled, or cancel it when the viewer
|
||||
* turned it off. Called on every launch; keeping an existing schedule
|
||||
* means a launch does not push the next look back.
|
||||
*/
|
||||
fun schedule(context: Context) {
|
||||
val work = WorkManager.getInstance(context.applicationContext)
|
||||
if (!SettingsStore(context).newEpisodeNotifications) {
|
||||
work.cancelUniqueWork(WORK_NAME)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.narl.protonstream.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import uniffi.pstr_android.AccountState
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* One watch-history sync after the app leaves the screen.
|
||||
*
|
||||
* The model's timer only runs while the app is alive, and Android is free to
|
||||
* kill a process in the background without notice — so the position the
|
||||
* viewer stopped at would wait for the next launch to reach their other
|
||||
* devices. Handed to WorkManager instead, it goes as soon as there is a
|
||||
* network, even if the process is gone by then.
|
||||
*/
|
||||
class WatchSyncWorker(
|
||||
appContext: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
val engine = NativeRuntime.engine()
|
||||
return runCatching { engine.syncWatchHistory() }.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = {
|
||||
// Signed out by the engine — the session ended — is not
|
||||
// something a retry fixes; a dropped connection is.
|
||||
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
|
||||
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "watch-history-sync"
|
||||
private const val MAX_ATTEMPTS = 3
|
||||
|
||||
/** Replacing: one pending sync covers every position saved before it runs. */
|
||||
fun enqueue(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Computer
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.FolderShared
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.DriveEntryRecord
|
||||
import uniffi.pstr_android.DrivePlaceRecord
|
||||
import uniffi.pstr_android.PlaceType
|
||||
import uniffi.pstr_android.verificationToken
|
||||
|
||||
/** What the account card and its dialogs can ask the model for. */
|
||||
internal data class AccountActions(
|
||||
val signIn: (String, String, String?) -> Unit,
|
||||
val submitSecondFactor: (String) -> Unit,
|
||||
val submitMailboxPassword: (String) -> Unit,
|
||||
val cancelSignIn: () -> Unit,
|
||||
val dismissVerification: () -> Unit,
|
||||
val signOut: () -> Unit,
|
||||
val syncNow: () -> Unit,
|
||||
val addFolder: (String, String, String) -> Unit,
|
||||
val onError: (Throwable) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Proton account, at the top of the shares page: why one would sign in,
|
||||
* or who is signed in and how watch-history sync is doing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccountCard(
|
||||
account: AccountUiState,
|
||||
actions: AccountActions,
|
||||
onSignIn: () -> Unit,
|
||||
onBrowse: () -> Unit,
|
||||
) {
|
||||
val state = account.state ?: return
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
if (state is AccountState.SignedIn) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sync now") },
|
||||
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
|
||||
onClick = { menu = false; actions.syncNow() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sign out") },
|
||||
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
|
||||
onClick = { menu = false; actions.signOut() },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
TonalButton(
|
||||
onClick = onBrowse,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null)
|
||||
Text("Add from your Drive", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Optional. Signed in, your shares, settings and watch history are kept in your own " +
|
||||
"Drive, so every device shows the same library and resumes where another left off, " +
|
||||
"and folders of your Drive can join the library.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TonalButton(onClick = onSignIn) { Text("Sign in") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||
account.syncError?.let { return "Did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Library, settings and watch history sync through your Drive"
|
||||
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||
val ago = when {
|
||||
minutes < 1 -> "just now"
|
||||
minutes < 60 -> "$minutes min ago"
|
||||
else -> "${minutes / 60} h ago"
|
||||
}
|
||||
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
|
||||
return "Synced $ago$applied"
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing in, one step at a time: address and password, then a second-factor
|
||||
* code or the mailbox password if the account has them, with Proton's CAPTCHA
|
||||
* over the top when it asks for one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var mailbox by remember { mutableStateOf("") }
|
||||
val state = account.state
|
||||
LaunchedEffect(state) {
|
||||
if (state is AccountState.SignedIn) onDismiss()
|
||||
}
|
||||
val dismiss = {
|
||||
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
|
||||
onDismiss()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = dismiss,
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Sign in to Proton") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> {
|
||||
OutlinedTextField(
|
||||
code,
|
||||
{ code = it },
|
||||
label = { Text("Code from your authenticator app") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
)
|
||||
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
AccountState.MailboxPassword -> {
|
||||
Text(
|
||||
"This account has a separate mailbox password. It unlocks your files.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
|
||||
}
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
username,
|
||||
{ username = it },
|
||||
label = { Text("Address") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
)
|
||||
PasswordField(password, { password = it }, "Password")
|
||||
}
|
||||
}
|
||||
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> AccentButton(
|
||||
onClick = { actions.submitSecondFactor(code.trim()) },
|
||||
enabled = code.isNotBlank() && !account.busy,
|
||||
) { Text("Continue") }
|
||||
AccountState.MailboxPassword -> AccentButton(
|
||||
onClick = { actions.submitMailboxPassword(mailbox) },
|
||||
enabled = mailbox.isNotEmpty() && !account.busy,
|
||||
) { Text("Unlock") }
|
||||
else -> AccentButton(
|
||||
onClick = { actions.signIn(username.trim(), password, null) },
|
||||
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
|
||||
) { Text("Sign in") }
|
||||
}
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
|
||||
)
|
||||
account.verificationUrl?.let { url ->
|
||||
VerificationDialog(
|
||||
url = url,
|
||||
onToken = { token -> actions.signIn(username.trim(), password, token) },
|
||||
onDismiss = actions.dismissVerification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
|
||||
value,
|
||||
onChange,
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
|
||||
/**
|
||||
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
|
||||
* recognise — a new network, a VPN exit.
|
||||
*
|
||||
* In a WebView rather than the browser because the page reports success by
|
||||
* posting a message to its host, not by redirecting: a browser has nowhere to
|
||||
* post it back to. The page targets `window.parent`, which for a top-level
|
||||
* page is the page itself, so a listener on its own window hears it.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
|
||||
@Composable
|
||||
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Confirm you are human",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f).padding(start = 16.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
val main = Handler(Looper.getMainLooper())
|
||||
var answered = false
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun post(message: String) {
|
||||
// Every message the page posts lands here,
|
||||
// most of them its own chatter.
|
||||
val token = verificationToken(message) ?: return
|
||||
main.post {
|
||||
if (!answered) {
|
||||
answered = true
|
||||
onToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pstrVerification",
|
||||
)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||
}
|
||||
|
||||
// The bridge above is reachable by whatever the
|
||||
// frame shows, so the frame stays on Proton.
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
|
||||
!isProtonPage(request.url.scheme, request.url.host)
|
||||
}
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security keys need WebAuthn, which the sign-in here does not speak. An account
|
||||
* with nothing else is refused before this form (`AccountStore::sign_in`).
|
||||
*/
|
||||
private const val SECURITY_KEY_NOTE =
|
||||
"Security keys are not supported here — use the code from your authenticator app."
|
||||
|
||||
/** Installed once per page, at start and again at finish in case the first ran too early. */
|
||||
private const val VERIFICATION_BRIDGE = """
|
||||
if (!window.__pstrVerification) {
|
||||
window.__pstrVerification = true;
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
/** Whether a page is Proton's own, over HTTPS. */
|
||||
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
|
||||
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
|
||||
|
||||
/**
|
||||
* Walk the account's Drive and add a folder of it to the library.
|
||||
*
|
||||
* Full screen, because a Drive is deep and a dialog's height is a few rows of
|
||||
* it. Listings are asked for here rather than through the model: they are
|
||||
* this dialog's alone, and gone when it closes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DriveBrowserDialog(
|
||||
actions: AccountActions,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
|
||||
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
|
||||
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val current = trail.lastOrNull()?.second
|
||||
|
||||
LaunchedEffect(current) {
|
||||
error = null
|
||||
if (current == null) {
|
||||
if (places == null) {
|
||||
runCatching { NativeRuntime.engine().drivePlaces() }
|
||||
.onSuccess { places = it }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
} else {
|
||||
entries = null
|
||||
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
|
||||
.onSuccess { entries = it }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
trail.lastOrNull()?.first ?: "Your Drive",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
|
||||
Icon(
|
||||
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// A folder already in the library is refused by the engine,
|
||||
// with a message saying so.
|
||||
val folder = current ?: return@Scaffold
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
|
||||
AccentButton(
|
||||
onClick = {
|
||||
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Add this folder") }
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
val loading = error == null && (if (current == null) places == null else entries == null)
|
||||
when {
|
||||
error != null -> EmptyState("Could not list this", error.orEmpty())
|
||||
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
|
||||
DriveRow(
|
||||
icon = when (place.kind) {
|
||||
PlaceType.MY_FILES -> Icons.Default.Storage
|
||||
PlaceType.DEVICE -> Icons.Default.Computer
|
||||
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
|
||||
},
|
||||
name = place.name,
|
||||
detail = null,
|
||||
onClick = { trail.add(place.name to place) },
|
||||
)
|
||||
}
|
||||
}
|
||||
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
|
||||
DriveRow(
|
||||
icon = when {
|
||||
entry.isFolder -> Icons.Default.Folder
|
||||
entry.isVideo -> Icons.Default.Movie
|
||||
else -> Icons.Default.InsertDriveFile
|
||||
},
|
||||
name = entry.name,
|
||||
detail = entry.size?.let(::formatBytes),
|
||||
onClick = if (entry.isFolder) {
|
||||
{
|
||||
trail.add(
|
||||
entry.name to DrivePlaceRecord(
|
||||
PlaceType.MY_FILES,
|
||||
entry.name,
|
||||
entry.volumeId,
|
||||
entry.linkId,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriveRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
name: String,
|
||||
detail: String?,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = detail?.let { { Text(it) } },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (onClick != null) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,13 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import uniffi.pstr_android.ArrangementRecord
|
||||
import uniffi.pstr_android.BrowseFacet
|
||||
import uniffi.pstr_android.SearchExtrasRecord
|
||||
import uniffi.pstr_android.LibraryFilter
|
||||
import uniffi.pstr_android.LibrarySort
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
@@ -33,12 +40,38 @@ import uniffi.pstr_android.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.SignInOutcome
|
||||
import uniffi.pstr_android.SyncRecord
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
/** A crawl is running, which the banner offers to stop. */
|
||||
val crawling: Boolean = false,
|
||||
val query: String = "",
|
||||
/**
|
||||
* The whole library, whatever is searched for: the grid narrows to the
|
||||
* search through [arrangement], and a title opened from a search, or from
|
||||
* an episode it found, stays one the app can show.
|
||||
*/
|
||||
val titles: List<TitleRecord> = emptyList(),
|
||||
/** What the search found besides titles; null with no search. */
|
||||
val searchExtras: SearchExtrasRecord? = null,
|
||||
/** The last few searches on this device, newest first. */
|
||||
val recentSearches: List<String> = emptyList(),
|
||||
/**
|
||||
* The grid: [titles] ordered, each franchise folded into one tile when
|
||||
* [grouped], and the shelves above it. Null until the bridge has answered,
|
||||
* when the grid falls back to [titles] as they are.
|
||||
*/
|
||||
val arrangement: ArrangementRecord? = null,
|
||||
val filter: LibraryFilter = LibraryFilter.ALL,
|
||||
val sort: LibrarySort = LibrarySort.NAME,
|
||||
val grouped: Boolean = true,
|
||||
val shares: List<ShareRecord> = emptyList(),
|
||||
val offline: List<OfflineRecord> = emptyList(),
|
||||
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord(
|
||||
@@ -49,14 +82,62 @@ data class AppUiState(
|
||||
),
|
||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||
val message: String? = null,
|
||||
/**
|
||||
* What is running in the background right now, said in a banner for as
|
||||
* long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||
* minute-long crawl looked like a tap that did nothing.
|
||||
*/
|
||||
val activity: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
val account: AccountUiState = AccountUiState(),
|
||||
)
|
||||
|
||||
/** The Proton account: where a sign-in stands, and how sync is doing. */
|
||||
data class AccountUiState(
|
||||
/** Null until the bridge has said. */
|
||||
val state: AccountState? = null,
|
||||
/** A sign-in step is out and not answered yet. */
|
||||
val busy: Boolean = false,
|
||||
/** Why the last sign-in step was refused. */
|
||||
val error: String? = null,
|
||||
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
|
||||
val verificationUrl: String? = null,
|
||||
/** When watch history last synced, in epoch milliseconds. */
|
||||
val syncedAt: Long? = null,
|
||||
/** Positions the last sync took from other devices. */
|
||||
val applied: Int = 0,
|
||||
/** Why the last sync failed, until one succeeds. */
|
||||
val syncError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||
private val appContext = context.applicationContext
|
||||
private val mutableState = MutableStateFlow(AppUiState())
|
||||
private val searchQuery = MutableStateFlow("")
|
||||
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
|
||||
/** Launch and coming back both ask; one crawl answers. */
|
||||
private var catchingUp = false
|
||||
|
||||
/**
|
||||
* Every banner line whose work is still running, oldest first. Touched on
|
||||
* the main thread only, as everything in [viewModelScope] is.
|
||||
*/
|
||||
private val activities = mutableListOf<Pair<Long, String>>()
|
||||
private var nextActivity = 0L
|
||||
|
||||
/** Crawls running now; main thread only, like [activities]. */
|
||||
private var crawlsRunning = 0
|
||||
|
||||
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
|
||||
private var crawlStopped = false
|
||||
|
||||
init {
|
||||
if (!NativeRuntime.tlsReady) {
|
||||
@@ -64,10 +145,44 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
// The arrangement first, so the first grid is drawn the way the viewer
|
||||
// left it rather than A–Z and then re-sorted under them.
|
||||
viewModelScope.launch {
|
||||
val (filter, sort, grouped) = withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).run {
|
||||
Triple(
|
||||
libraryFilter?.let { name -> LibraryFilter.entries.firstOrNull { it.name == name } },
|
||||
librarySort?.let { name -> LibrarySort.entries.firstOrNull { it.name == name } },
|
||||
libraryGrouped,
|
||||
)
|
||||
}
|
||||
}
|
||||
mutableState.update { it.copy(filter = filter ?: it.filter, sort = sort ?: it.sort, grouped = grouped) }
|
||||
reload()
|
||||
}
|
||||
catchUp()
|
||||
viewModelScope.launch {
|
||||
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||
.getOrDefault(AccountState.SignedOut)
|
||||
updateAccount { it.copy(state = account) }
|
||||
// While the app is alive, on top of after sign-in and whenever the
|
||||
// player closes. Cheap when nothing changed: one folder listing,
|
||||
// and no upload.
|
||||
while (isActive) {
|
||||
syncWatchHistory()
|
||||
// A sync WatchSyncWorker ran while the app was away may have
|
||||
// brought shares this one has not crawled.
|
||||
catchUp()
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().recentSearches() } }
|
||||
.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
workManager.workInfosByTagFlow(DownloadCoordinator.TAG)
|
||||
.map { work -> work.filter { it.state.isFinished }.map { it.id }.toSet() }
|
||||
@@ -77,21 +192,128 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
|
||||
/** Sort and fold the library, and keep both for the next launch. */
|
||||
fun arrange(sort: LibrarySort, grouped: Boolean) {
|
||||
mutableState.update { it.copy(sort = sort, grouped = grouped) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
SettingsStore(appContext).apply {
|
||||
librarySort = sort.name
|
||||
libraryGrouped = grouped
|
||||
}
|
||||
}
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow the grid to some titles, and keep the choice for the next launch. */
|
||||
fun filter(filter: LibraryFilter) {
|
||||
mutableState.update { it.copy(filter = filter) }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) { SettingsStore(appContext).libraryFilter = filter.name }
|
||||
reloadLibrary(mutableState.value.query)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
mutableState.update { it.copy(query = query) }
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the search being shown as one to offer again: called when a viewer
|
||||
* opens something it found. Nothing for an empty one.
|
||||
*/
|
||||
fun rememberSearch() {
|
||||
val query = mutableState.value.query.takeIf(String::isNotBlank) ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().run {
|
||||
rememberSearch(query)
|
||||
recentSearches()
|
||||
}
|
||||
}
|
||||
}.onSuccess { recent -> mutableState.update { it.copy(recentSearches = recent) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun forgetSearches() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetSearches() } }
|
||||
.onSuccess { mutableState.update { it.copy(recentSearches = emptyList()) } }
|
||||
.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
busy("Refreshing the library…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(null) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up whatever a crawl brought that has no answer yet: a new show, or
|
||||
* a new episode its stored guide cannot name. When everything already has
|
||||
* one the bridge asks nothing, so this is cheap after every crawl.
|
||||
*/
|
||||
private suspend fun matchAfterCrawl() {
|
||||
val settings = mutableState.value.metadataSettings
|
||||
if (settings.enabled && settings.ready) match(force = false, quiet = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Say [what] in the activity banner while [work] runs.
|
||||
*
|
||||
* The banner shows the newest line still running. Work overlaps — a sync
|
||||
* starts during a crawl and ends after it — so each line is removed by
|
||||
* itself rather than by putting back whatever was showing before, which
|
||||
* once brought back a finished crawl's line and spun it forever.
|
||||
*/
|
||||
private suspend fun busy(what: String, work: suspend () -> Unit) {
|
||||
val token = nextActivity++
|
||||
activities += token to what
|
||||
showActivity()
|
||||
try {
|
||||
work()
|
||||
} finally {
|
||||
activities.removeAll { it.first == token }
|
||||
showActivity()
|
||||
}
|
||||
}
|
||||
|
||||
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
|
||||
private suspend fun crawlShares(id: String?) {
|
||||
crawlsRunning++
|
||||
mutableState.update { it.copy(crawling = true) }
|
||||
try {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
|
||||
} finally {
|
||||
crawlsRunning--
|
||||
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the crawls running now. Shares already read stay in the library;
|
||||
* the one being read keeps what it had.
|
||||
*/
|
||||
fun stopCrawl() {
|
||||
crawlStopped = true
|
||||
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
|
||||
}
|
||||
|
||||
private fun showActivity() {
|
||||
val newest = activities.lastOrNull()?.second
|
||||
mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recrawl one share rather than the whole library.
|
||||
*
|
||||
@@ -102,11 +324,14 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
*/
|
||||
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()
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
|
||||
busy("Reading $name…") {
|
||||
mutableState.update { it.copy(message = null) }
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,26 +344,100 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* changed.
|
||||
*/
|
||||
fun matchTitles(force: Boolean) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true, message = null) }
|
||||
viewModelScope.launch { match(force) }
|
||||
}
|
||||
|
||||
/**
|
||||
* With [quiet], a run nobody asked for says nothing when it found nothing
|
||||
* to do.
|
||||
*/
|
||||
private suspend fun match(force: Boolean, quiet: Boolean = false) {
|
||||
busy("Looking up titles…") {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
|
||||
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
|
||||
.onSuccess { summary ->
|
||||
if (!quiet || !summary.isEmpty()) {
|
||||
mutableState.update { it.copy(message = describe(summary)) }
|
||||
}
|
||||
}
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
reloadNow()
|
||||
}
|
||||
|
||||
private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u
|
||||
|
||||
/**
|
||||
* Add a link, and return why it was refused, if it was.
|
||||
*
|
||||
* The link is opened before anything is stored, so the form can stay up
|
||||
* with the reason under it and a retry is a retry — not "already in the
|
||||
* library" for a share that never opened. Only the new share is crawled:
|
||||
* another share that fails to open must not make this one look broken.
|
||||
*/
|
||||
suspend fun addShare(name: String, url: String, password: String?): String? {
|
||||
val share = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeRuntime.engine().addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
}
|
||||
}.getOrElse { return it.message ?: "That link could not be added" }
|
||||
reload()
|
||||
viewModelScope.launch { crawlAdded(listOf(share.id), announce = true) }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl shares that are new here — added on this device or by sync — then
|
||||
* match them when enrichment is on.
|
||||
*/
|
||||
private suspend fun crawlAdded(ids: List<String>, announce: Boolean) {
|
||||
if (ids.isEmpty()) return
|
||||
val before = mutableState.value.titles.map { it.key }.toSet()
|
||||
val failed = mutableListOf<String>()
|
||||
crawlStopped = false
|
||||
for (id in ids) {
|
||||
if (crawlStopped) break
|
||||
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
|
||||
busy("Reading $name…") {
|
||||
runCatching { crawlShares(id) }
|
||||
.onFailure { failed += "$name: ${it.message}" }
|
||||
}
|
||||
}
|
||||
reloadNow()
|
||||
// A catch-up is the app's doing, not the viewer's: a failure goes on
|
||||
// the share's row rather than in their face, and only news is said.
|
||||
if (failed.isNotEmpty() && announce) {
|
||||
mutableState.update { it.copy(message = failed.joinToString("\n")) }
|
||||
return
|
||||
}
|
||||
matchAfterCrawl()
|
||||
if (announce) {
|
||||
val added = (mutableState.value.titles.map { it.key }.toSet() - before).size
|
||||
mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") }
|
||||
} else {
|
||||
val files = mutableState.value.shares.filter { it.id in ids }.sumOf { it.crawlAdded ?: 0uL }
|
||||
if (files > 0uL) {
|
||||
mutableState.update { it.copy(message = if (files == 1uL) "1 new file" else "$files new files") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addShare(name: String, url: String, password: String?) {
|
||||
/**
|
||||
* Crawl shares due a look: ones the catalog has nothing of — brought by a
|
||||
* sync that ran in [io.narl.protonstream.sync.WatchSyncWorker], or an add
|
||||
* whose crawl never finished — and ones not crawled for six hours, so new
|
||||
* episodes turn up without a pull to refresh.
|
||||
*/
|
||||
fun catchUp() {
|
||||
if (catchingUp) return
|
||||
catchingUp = true
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
engine.addShare(name, url, password?.takeIf(String::isNotBlank))
|
||||
engine.crawl(null)
|
||||
}
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
reload()
|
||||
try {
|
||||
val ids = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().uncrawledShares() } }
|
||||
.getOrDefault(emptyList())
|
||||
crawlAdded(ids, announce = false)
|
||||
} finally {
|
||||
catchingUp = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +459,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
engine.crawl(id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reloadNow()
|
||||
matchAfterCrawl()
|
||||
}
|
||||
}
|
||||
|
||||
/** Call a share something else. The library, history and downloads stay. */
|
||||
fun renameShare(id: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().renameShare(id, name) } }
|
||||
.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
@@ -185,28 +494,315 @@ 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) }
|
||||
|
||||
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
|
||||
mutableState.update { it.copy(account = change(it.account)) }
|
||||
|
||||
/**
|
||||
* Start signing in, or start again with [verificationToken] once the
|
||||
* CAPTCHA from a first try is solved.
|
||||
*/
|
||||
fun signIn(username: String, password: String, verificationToken: String? = null) =
|
||||
signInStep { engine -> engine.signIn(username, password, verificationToken) }
|
||||
|
||||
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
|
||||
|
||||
fun submitMailboxPassword(password: String) =
|
||||
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
|
||||
|
||||
fun cancelSignIn() {
|
||||
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
|
||||
}
|
||||
|
||||
/** The CAPTCHA page closed without a token. */
|
||||
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
|
||||
|
||||
private fun signInStep(
|
||||
keepStepOnFailure: Boolean = false,
|
||||
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
|
||||
) {
|
||||
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
|
||||
.onSuccess { outcome ->
|
||||
when (outcome) {
|
||||
is SignInOutcome.Done -> {
|
||||
updateAccount {
|
||||
AccountUiState(state = AccountState.SignedIn(outcome.username))
|
||||
}
|
||||
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
|
||||
syncWatchHistory()
|
||||
reload()
|
||||
}
|
||||
SignInOutcome.SecondFactor ->
|
||||
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
|
||||
SignInOutcome.MailboxPassword ->
|
||||
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
|
||||
is SignInOutcome.HumanVerification ->
|
||||
updateAccount { it.copy(verificationUrl = outcome.url) }
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
updateAccount {
|
||||
it.copy(
|
||||
busy = false,
|
||||
error = error.message ?: "Sign-in failed",
|
||||
// A refused code spends the half-made session.
|
||||
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
|
||||
.onFailure(::reportError)
|
||||
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync now, from a tap: says so when it is done. */
|
||||
fun syncNow() {
|
||||
viewModelScope.launch {
|
||||
if (syncWatchHistory()) {
|
||||
val applied = mutableState.value.account.applied
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
message = if (applied > 0) {
|
||||
"Watch history synced, $applied from other devices"
|
||||
} else {
|
||||
"Watch history is up to date"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||
* on a timer, and a failure is shown on the account card rather than as a
|
||||
* snackbar every five minutes of an offline evening. Returns whether it
|
||||
* synced.
|
||||
*/
|
||||
private suspend fun syncWatchHistory(): Boolean {
|
||||
if (mutableState.value.account.state !is AccountState.SignedIn) return false
|
||||
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
|
||||
.fold(
|
||||
onSuccess = { report ->
|
||||
if (report == null) return false
|
||||
updateAccount {
|
||||
it.copy(
|
||||
syncedAt = System.currentTimeMillis(),
|
||||
applied = report.applied.toInt(),
|
||||
syncError = null,
|
||||
)
|
||||
}
|
||||
takeSynced(report)
|
||||
true
|
||||
},
|
||||
onFailure = { error ->
|
||||
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
|
||||
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
|
||||
// Signed out by the engine: the session ended, and that is
|
||||
// worth interrupting for, unlike a dropped connection.
|
||||
if (state == AccountState.SignedOut) reportError(error)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What another device changed: its shares crawled or dropped here, its
|
||||
* settings repainted.
|
||||
*/
|
||||
private suspend fun takeSynced(report: SyncRecord) {
|
||||
if (report.sharesRemoved.isNotEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val downloads = DownloadStateStore(appContext)
|
||||
report.sharesRemoved.forEach { id ->
|
||||
workManager.cancelAllWorkByTag(DownloadCoordinator.shareTag(id)).result.get()
|
||||
downloads.removeShare(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (report.settingsChanged) AppearanceState.reload()
|
||||
if (report.applied > 0u || report.titles > 0u || report.settingsChanged || report.listChanged ||
|
||||
report.sharesRemoved.isNotEmpty()
|
||||
) {
|
||||
reloadNow()
|
||||
}
|
||||
if (report.sharesAdded.isNotEmpty()) {
|
||||
crawlAdded(report.sharesAdded, announce = false)
|
||||
val count = report.sharesAdded.size
|
||||
mutableState.update {
|
||||
it.copy(message = if (count == 1) "A share arrived from another device" else "$count shares arrived from other devices")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After the player closes: where it stopped is what another device wants next. */
|
||||
fun playerClosed() {
|
||||
reload()
|
||||
viewModelScope.launch { syncWatchHistory() }
|
||||
}
|
||||
|
||||
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().addAccountFolder(name, volumeId, linkId) }
|
||||
}.onSuccess { share ->
|
||||
reload()
|
||||
crawlAdded(listOf(share.id), announce = true)
|
||||
}.onFailure(::reportError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
* menu should not cost them.
|
||||
*/
|
||||
/** Put a title on My List, or take it off. */
|
||||
fun setOnList(title: TitleRecord, on: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().setOnList(title.key, on) }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys of the titles most like [key], best first; none if the engine cannot say. */
|
||||
suspend fun moreLike(key: String): List<String> =
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().moreLike(key) } }.getOrDefault(emptyList())
|
||||
|
||||
/** The keys of every title [facet] stands for, in library order. */
|
||||
suspend fun browse(facet: BrowseFacet): List<String> =
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().browse(facet) } }
|
||||
.onFailure(::reportError)
|
||||
.getOrDefault(emptyList())
|
||||
|
||||
fun setTitleWatched(title: TitleRecord, watched: Boolean) =
|
||||
markEpisodes(title.seasons.flatMap { it.episodes }, watched, title.displayName)
|
||||
|
||||
/**
|
||||
* Some episodes seen or unseen — a season, or a whole title — with an
|
||||
* Undo; [what] names them in the message.
|
||||
*/
|
||||
fun markEpisodes(episodes: List<EpisodeRecord>, watched: Boolean, what: String) {
|
||||
changeWatch(
|
||||
episodes.filter { it.watched != watched },
|
||||
"$what 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 ->
|
||||
engine.restoreWatchState(snapshot.shareId, snapshot.linkId, snapshot.state)
|
||||
}
|
||||
}
|
||||
}.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) {
|
||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||
}
|
||||
|
||||
fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
if (enabled) engine.matchTitles(false) else null
|
||||
/**
|
||||
* Store the enrichment settings, and return why not, if they were not.
|
||||
*
|
||||
* The page shows the new settings as soon as they are stored; the match
|
||||
* run that follows can take minutes and says so in the activity banner.
|
||||
*/
|
||||
suspend fun saveMetadataSettings(enabled: Boolean, provider: MetadataProvider, language: String, apiKey: String): String? {
|
||||
val stored = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// TMDB's is the only key: the provider's with TMDB chosen, and
|
||||
// film art's with AniList.
|
||||
if (apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
|
||||
}
|
||||
}.onSuccess { summary ->
|
||||
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
engine.metadataSettings()
|
||||
}
|
||||
}.getOrElse { return it.message ?: "The settings could not be saved" }
|
||||
mutableState.update { it.copy(metadataSettings = stored) }
|
||||
reloadNow()
|
||||
if (enabled) viewModelScope.launch { match(force = false) }
|
||||
return null
|
||||
}
|
||||
|
||||
fun reloadAfterMetadataChange() = reload()
|
||||
@@ -289,24 +885,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
* at its duration where one is known, so the progress bar agrees with the
|
||||
* tick.
|
||||
*/
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
|
||||
engine.saveWatchState(
|
||||
episode.shareId,
|
||||
episode.linkId,
|
||||
if (watched) duration ?: 0.0 else 0.0,
|
||||
duration,
|
||||
watched,
|
||||
)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
/** One episode's tick, with the same Undo a season's has. */
|
||||
fun setWatched(episode: EpisodeRecord, watched: Boolean) = markEpisodes(listOf(episode), watched, episode.label)
|
||||
|
||||
fun removeAllOffline() {
|
||||
viewModelScope.launch {
|
||||
@@ -333,43 +913,64 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
engine.pruneOfflineFiles()
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch { reloadNow() }
|
||||
}
|
||||
|
||||
/** [reload], for a caller that goes on to read what it loaded. */
|
||||
private suspend fun reloadNow() {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
engine.pruneOfflineFiles()
|
||||
val current = mutableState.value
|
||||
val query = current.query.takeIf(String::isNotBlank)
|
||||
Reloaded(
|
||||
engine.shares(),
|
||||
engine.library(null),
|
||||
engine.arrangement(query, current.filter, current.sort, current.grouped),
|
||||
engine.offlineFiles(),
|
||||
engine.metadataSettings(),
|
||||
engine.storageUsage(),
|
||||
)
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
mutableState.update { it.copy(
|
||||
loading = false,
|
||||
shares = result.shares,
|
||||
titles = result.titles,
|
||||
arrangement = result.arrangement,
|
||||
offline = result.offline,
|
||||
metadataSettings = result.metadataSettings,
|
||||
storage = result.storage,
|
||||
) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
mutableState.update { it.copy(loading = false, message = error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadLibrary(query: String) {
|
||||
val current = mutableState.value
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) }
|
||||
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } }
|
||||
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val search = query.takeIf(String::isNotBlank)
|
||||
Triple(
|
||||
engine.library(null),
|
||||
engine.arrangement(search, current.filter, current.sort, current.grouped),
|
||||
search?.let(engine::searchExtras),
|
||||
)
|
||||
}
|
||||
}.onSuccess { (titles, arrangement, extras) ->
|
||||
mutableState.update { it.copy(titles = titles, arrangement = arrangement, searchExtras = extras) }
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||
@@ -381,6 +982,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
private data class Reloaded(
|
||||
val shares: List<ShareRecord>,
|
||||
val titles: List<TitleRecord>,
|
||||
val arrangement: ArrangementRecord,
|
||||
val offline: List<OfflineRecord>,
|
||||
val metadataSettings: MetadataSettingsRecord,
|
||||
val storage: StorageUsageRecord,
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
* 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, action: Pair<String, () -> Unit>? = null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
action?.let { (label, onClick) ->
|
||||
AccentButton(onClick = onClick, modifier = Modifier.padding(top = 16.dp)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Play on a title lands: what Continue watching offers
|
||||
* ([TitleRecord.continueIndex]), else the most recently played part-watched
|
||||
* episode, else the first unwatched one, else the first — the desktop's
|
||||
* `next_up`.
|
||||
*/
|
||||
internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = null): Int =
|
||||
continueIndex?.toInt()?.takeIf { it in playlist.indices }
|
||||
?: playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
|
||||
internal fun formatRuntime(minutes: UInt): String {
|
||||
val hours = minutes / 60u
|
||||
val rest = minutes % 60u
|
||||
return when {
|
||||
hours == 0u -> "$rest min"
|
||||
rest == 0u -> "$hours h"
|
||||
else -> "$hours h $rest min"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download episodes, asking first when it is more than one: a season or a
|
||||
* show is tens of gigabytes, and one tap should not fill a phone. The
|
||||
* question says how many and how big. Call it in composition; it draws the
|
||||
* dialog where it is called and returns what starts the download.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberDownload(): (List<EpisodeRecord>, String) -> Unit {
|
||||
val context = LocalContext.current
|
||||
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, String>?>(null) }
|
||||
asking?.let { (episodes, what) ->
|
||||
val bytes = episodes.sumOf { it.size ?: 0uL }
|
||||
AlertDialog(
|
||||
onDismissRequest = { asking = null },
|
||||
title = { Text("Download ${episodes.size} episodes of $what?") },
|
||||
text = {
|
||||
Text(
|
||||
"They are kept on this device for watching without a connection" +
|
||||
(if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") +
|
||||
". Delete them from Downloads.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(onClick = {
|
||||
asking = null
|
||||
DownloadCoordinator.enqueue(context, episodes)
|
||||
}) { Text("Download") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
return remember(context) {
|
||||
{ episodes, what ->
|
||||
val wanted = episodes.filterNot(EpisodeRecord::offline)
|
||||
if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
|
||||
* [waitingFor], what it is really waiting on.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload, waitingFor: String? = null): String = listOfNotNull(
|
||||
waitingFor?.takeIf { download.status == RetainedDownload.STATUS_QUEUED }
|
||||
?: download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
download.bytesPerSecond
|
||||
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
|
||||
?.let { "${formatBytes(it.toULong())}/s" },
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* Why a queued download is not running, when it is not merely a free slot:
|
||||
* WorkManager holds a Wi-Fi-only download back on mobile data without a word,
|
||||
* and "Queued" for an hour on the bus reads as stuck. Re-read whenever [key]
|
||||
* changes — the download's own updates are the clock.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberQueueReason(key: Any?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(key) {
|
||||
val network = context.getSystemService(ConnectivityManager::class.java)
|
||||
when {
|
||||
network?.activeNetwork == null -> "Waiting for a connection"
|
||||
SettingsStore(context).wifiOnly && network.isActiveNetworkMetered -> "Waiting for Wi-Fi"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode's own name: the provider's, else the one the filename gave it.
|
||||
* `null` rather than the filename, which is a release group's tag and the
|
||||
* episode's numbering again.
|
||||
*/
|
||||
internal fun EpisodeRecord.shownName(): String? = providerName ?: detail.takeIf { it != name && it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* 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 = shownName()
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the title is filed under — a genre — as a small rounded label.
|
||||
* The desktop's `widgets::chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenreChip(text: String, onClick: (() -> Unit)? = null) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.then(if (onClick != null) Modifier.clickable(onClickLabel = "Browse $text", onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
|
||||
* [GenreChip] with a dot in front: the accent while more is coming, muted once
|
||||
* it is all out. The desktop's `widgets::status_chip`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StatusChip(text: String, live: Boolean, modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier
|
||||
.background(scheme.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.background(if (live) scheme.primary else scheme.onSurfaceVariant, CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.labelMedium, color = scheme.onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fact about the files rather than the title — 4K, HDR, Dual audio — as a
|
||||
* small outlined mark, the way a box prints them. Quieter than a [GenreChip].
|
||||
* The desktop's `widgets::spec_badge`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpecBadge(text: String) {
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = ink,
|
||||
modifier = Modifier
|
||||
.border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* My List's button: a plus to put the title on it, a tick once it is, which
|
||||
* takes it off again. [compact] is the icon alone, for the banner's row, where
|
||||
* a third labelled button does not fit a phone.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ListButton(onList: Boolean, onSet: (Boolean) -> Unit, compact: Boolean = false) {
|
||||
val icon = if (onList) Icons.Default.Check else Icons.Default.Add
|
||||
val label = if (onList) "Remove from My List" else "Add to My List"
|
||||
if (compact) {
|
||||
FilledTonalIconButton(onClick = { onSet(!onList) }, shape = MaterialTheme.shapes.small) {
|
||||
Icon(icon, contentDescription = label)
|
||||
}
|
||||
} else {
|
||||
TonalButton(onClick = { onSet(!onList) }, modifier = Modifier.semantics { contentDescription = label }) {
|
||||
Icon(icon, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("My List")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.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.DangerButton
|
||||
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,
|
||||
onPlay: (TitleRecord, Int) -> Unit = { _, _ -> },
|
||||
onOpen: (TitleRecord) -> Unit = {},
|
||||
) {
|
||||
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 }, onPlay = onPlay, onOpen = onOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
DangerButton(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,
|
||||
onPlay: (TitleRecord, Int) -> Unit,
|
||||
onOpen: (TitleRecord) -> Unit,
|
||||
) {
|
||||
val episode = file.episode
|
||||
// What a download is for: tapping it plays it, from where it was left,
|
||||
// without going back through the library to find it.
|
||||
val index = title?.seasons?.flatMap(SeasonRecord::episodes)?.indexOfFirst { it.linkId == file.linkId } ?: -1
|
||||
ListItem(
|
||||
modifier = if (title != null && index >= 0) {
|
||||
Modifier.clickable(onClickLabel = "Play") { onPlay(title, index) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
leadingContent = {
|
||||
RemoteArtwork(
|
||||
episode?.stillUrl ?: title?.backdropUrl,
|
||||
title?.name ?: file.linkId,
|
||||
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
|
||||
fallback = episode?.thumbnailSource,
|
||||
labelled = episode == null,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
title?.let {
|
||||
IconButton(onClick = { onOpen(it) }) {
|
||||
Icon(Icons.Outlined.Info, contentDescription = "Open ${it.displayName}")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A download not yet finished: how far it has got, why it stopped if it did,
|
||||
* and pause or resume beside cancel.
|
||||
*/
|
||||
@Composable
|
||||
private fun PartialRow(
|
||||
download: RetainedDownload,
|
||||
onPause: (RetainedDownload) -> Unit,
|
||||
onResume: (RetainedDownload) -> Unit,
|
||||
onDeletePartial: (RetainedDownload) -> Unit,
|
||||
) {
|
||||
val active = download.status == RetainedDownload.STATUS_RUNNING ||
|
||||
download.status == RetainedDownload.STATUS_QUEUED
|
||||
ListItem(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(downloadStatusLine(download, rememberQueueReason(download)))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (active) {
|
||||
IconButton(onClick = { onPause(download) }) {
|
||||
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = { onResume(download) }) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
|
||||
}
|
||||
}
|
||||
// What has arrived so far is discarded; nothing finished is.
|
||||
IconButton(onClick = { onDeletePartial(download) }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,15 @@ import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -16,17 +24,33 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.core.graphics.scale
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.moodVeil as sharedMoodVeil
|
||||
import uniffi.pstr_android.pictureMood as sharedPictureMood
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -64,7 +88,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
||||
*
|
||||
* [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.
|
||||
* one. Until either answers, the box shimmers 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.
|
||||
*
|
||||
* Art that arrives fades in over the skeleton rather than replacing it in one
|
||||
* frame, which across a grid reads as flicker. Art already decoded is drawn
|
||||
* at once: a tile scrolled back into view has nothing to arrive.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteArtwork(
|
||||
@@ -72,19 +102,291 @@ internal fun RemoteArtwork(
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: ThumbnailSource? = null,
|
||||
labelled: Boolean = true,
|
||||
maxEdge: Dp? = null,
|
||||
onMood: ((Color) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, fallback) {
|
||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
||||
val enabled = LocalArtworkLoading.current
|
||||
// Decoded at the size it is drawn: a grid tile is a few hundred pixels,
|
||||
// and decoding its poster at 1024 holds four times the memory for nothing.
|
||||
val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
|
||||
var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
|
||||
LaunchedEffect(url, fallback, enabled) {
|
||||
if (!enabled) {
|
||||
image = ArtworkLoad.Missing
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
|
||||
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
|
||||
if (bitmap != null && onMood != null) {
|
||||
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
|
||||
}
|
||||
}
|
||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
||||
image?.let {
|
||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
||||
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
|
||||
when (loaded) {
|
||||
ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
|
||||
ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled)
|
||||
is ArtworkLoad.Loaded -> Image(
|
||||
loaded.image,
|
||||
description,
|
||||
Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the decoded cache already holds for this art, so it skips the fade. */
|
||||
private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad {
|
||||
val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) }
|
||||
return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending
|
||||
}
|
||||
|
||||
/**
|
||||
* How long art takes to fade in over its skeleton: the desktop's
|
||||
* `motion::ARRIVE`.
|
||||
*/
|
||||
private const val ARRIVE_MS = 240
|
||||
|
||||
/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */
|
||||
private const val SHIMMER_MS = 1_400
|
||||
|
||||
/**
|
||||
* A title's art across the top of a page: its backdrop, or with none, its
|
||||
* poster as a wash of its own colours — decoded tiny and blurred, so it reads as
|
||||
* the poster's light rather than a blown-up slice of it. The desktop's
|
||||
* `Fit::Ambient`. Android 11 and older cannot blur; there the tiny decode,
|
||||
* stretched, is what softens it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
|
||||
val wash = title.backdropUrl == null && title.posterUrl != null
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.displayName,
|
||||
if (wash) modifier.blur(WASH_BLUR) else modifier,
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
maxEdge = if (wash) WASH_EDGE else null,
|
||||
onMood = onMood,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a picture leaves behind — the shared `appearance::mood`, over the
|
||||
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
|
||||
* banner paging back to a title does not work it out again.
|
||||
*/
|
||||
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
|
||||
moods.get(key)?.let { return Color(it) }
|
||||
val argb = withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
val small = bitmap.scale(MOOD_EDGE, MOOD_EDGE)
|
||||
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
|
||||
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
|
||||
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
|
||||
}.getOrNull()
|
||||
} ?: return null
|
||||
moods.put(key, argb)
|
||||
return Color(argb)
|
||||
}
|
||||
|
||||
private val moods = LruCache<String, Int>(64)
|
||||
|
||||
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
|
||||
private const val MOOD_EDGE = 24
|
||||
|
||||
/**
|
||||
* [background] tinted by a picture's [mood], for the veils over that picture —
|
||||
* the shared `appearance::mood_veil`. [background] itself until the mood is
|
||||
* known.
|
||||
*/
|
||||
@Composable
|
||||
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
|
||||
val light = background.luminance() > 0.5f
|
||||
val target = remember(mood, background) {
|
||||
mood?.let {
|
||||
runCatching {
|
||||
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
|
||||
}.getOrNull()
|
||||
} ?: background
|
||||
}
|
||||
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
|
||||
}
|
||||
|
||||
private val WASH_EDGE = 48.dp
|
||||
private val WASH_BLUR = 24.dp
|
||||
|
||||
/**
|
||||
* A title's logo — its name as lettering — in place of [fallback], once it has
|
||||
* loaded. [fallback] stands until then, and for good when there is none, so a
|
||||
* title without a logo looks as it always did and one with a logo only gains.
|
||||
*
|
||||
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
|
||||
* logo to a square, and the padding would otherwise decide its size and place.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteLogo(
|
||||
url: String?,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, enabled) {
|
||||
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
|
||||
}
|
||||
Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
|
||||
when (loaded) {
|
||||
null -> fallback()
|
||||
else -> Image(
|
||||
loaded,
|
||||
description,
|
||||
modifier,
|
||||
alignment = Alignment.BottomStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
|
||||
val requested = url ?: return null
|
||||
val key = "logo:$requested"
|
||||
decoded.get(key)?.let { return it }
|
||||
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
|
||||
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
|
||||
}
|
||||
|
||||
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
|
||||
private fun trimTransparent(bitmap: Bitmap): Bitmap {
|
||||
if (!bitmap.hasAlpha()) return bitmap
|
||||
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
|
||||
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
|
||||
* row-major image of [width] that are not (nearly) transparent; `null` when
|
||||
* none are. Below the threshold is anti-aliasing haze, not lettering.
|
||||
*/
|
||||
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
|
||||
var left = Int.MAX_VALUE
|
||||
var top = Int.MAX_VALUE
|
||||
var right = -1
|
||||
var bottom = -1
|
||||
for (index in pixels.indices) {
|
||||
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
|
||||
val x = index % width
|
||||
val y = index / width
|
||||
if (x < left) left = x
|
||||
if (x > right) right = x
|
||||
if (y < top) top = y
|
||||
if (y > bottom) bottom = y
|
||||
}
|
||||
return if (right < 0) null else intArrayOf(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private const val VISIBLE_ALPHA = 8
|
||||
|
||||
/** A logo is drawn a few hundred pixels wide at most. */
|
||||
private const val LOGO_EDGE = 800
|
||||
|
||||
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, with a soft highlight sweeping
|
||||
* across it on a diagonal.
|
||||
*
|
||||
* 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.
|
||||
* The sweep is measured in the tile's own width, so neighbouring tiles of one
|
||||
* size shimmer in step, as one surface.
|
||||
*/
|
||||
@Composable
|
||||
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||
val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = -1f,
|
||||
targetValue = 2f,
|
||||
animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
|
||||
label = "skeleton sweep",
|
||||
)
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f)
|
||||
Box(
|
||||
modifier.drawBehind {
|
||||
drawRect(base)
|
||||
val centre = size.width * sweep
|
||||
val half = size.width * 0.6f
|
||||
drawRect(
|
||||
Brush.linearGradient(
|
||||
listOf(Color.Transparent, highlight, Color.Transparent),
|
||||
start = Offset(centre - half, 0f),
|
||||
end = Offset(centre + half, size.height),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -93,8 +395,8 @@ internal fun RemoteArtwork(
|
||||
* loader's protocol checks and exactly this disk cache, so the artwork the shade
|
||||
* shows costs no second download.
|
||||
*/
|
||||
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
|
||||
decoded.get(requested) ?: withContext(Dispatchers.IO) {
|
||||
internal suspend fun loadArtwork(context: Context, url: String?, edge: Int = MAX_ART_EDGE): Bitmap? = url?.let { requested ->
|
||||
decoded.get(sized(requested, edge)) ?: withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val uri = URI(requested)
|
||||
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
|
||||
@@ -104,7 +406,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
val cached = directory.resolve("$digest.img")
|
||||
val fresh = !cached.isFile
|
||||
val bytes = if (fresh) download(uri) else cached.readBytes()
|
||||
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
|
||||
val bitmap = requireNotNull(decodeSampled(bytes, edge)) { "Artwork is not a decodable image" }
|
||||
if (fresh) {
|
||||
// Written only once it has decoded. Caching the bytes first is
|
||||
// what turns one hostile poster into a crash on every launch
|
||||
@@ -117,7 +419,7 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
cached.setLastModified(System.currentTimeMillis())
|
||||
}
|
||||
bitmap
|
||||
}.getOrNull()?.also { decoded.put(requested, it) }
|
||||
}.getOrNull()?.also { decoded.put(sized(requested, edge), it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,21 +433,21 @@ internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?
|
||||
* share or a session that has to be reopened is transient, and the next
|
||||
* composition should try again.
|
||||
*/
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
|
||||
internal suspend fun loadThumbnail(source: ThumbnailSource?, edge: Int = MAX_ART_EDGE): Bitmap? {
|
||||
val file = source ?: return null
|
||||
val key = file.key
|
||||
decoded.get(key)?.let { return it }
|
||||
decoded.get(sized(key, edge))?.let { return it }
|
||||
if (key in missing) return null
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
|
||||
}
|
||||
val bytes = fetched.getOrElse { return null }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
|
||||
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it, edge) } }
|
||||
if (bitmap == null) {
|
||||
missing.add(key)
|
||||
return null
|
||||
}
|
||||
decoded.put(key, bitmap)
|
||||
decoded.put(sized(key, edge), bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@@ -164,28 +466,43 @@ private fun download(uri: URI): ByteArray {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode within a bound the display can actually use.
|
||||
* Decode with its longest edge at most [edge] pixels.
|
||||
*
|
||||
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
|
||||
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
|
||||
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
|
||||
* costs no allocation and settles a sample size before anything is committed.
|
||||
*
|
||||
* Sampling halves, so it stops while the image is still at least [edge]; the
|
||||
* decoder scales the rest of the way, which a poster sampled straight to under
|
||||
* its tile would show as blur.
|
||||
*/
|
||||
private fun decodeSampled(bytes: ByteArray): Bitmap? {
|
||||
private fun decodeSampled(bytes: ByteArray, edge: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
if (longest <= 0) return null
|
||||
var sample = 1
|
||||
while (longest / sample > MAX_ART_EDGE) sample *= 2
|
||||
while (longest / (sample * 2) >= edge) sample *= 2
|
||||
val sampled = longest / sample
|
||||
return BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
if (sampled > edge) {
|
||||
inScaled = true
|
||||
inDensity = sampled
|
||||
inTargetDensity = edge
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The decoded cache's key for one image at one size. */
|
||||
private fun sized(key: String, edge: Int) = "$key#$edge"
|
||||
|
||||
/**
|
||||
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
|
||||
*
|
||||
@@ -222,7 +539,7 @@ private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSet
|
||||
* is a full-resolution decode per item.
|
||||
*/
|
||||
private val decoded = object : LruCache<String, Bitmap>(
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
|
||||
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 64L * 1024 * 1024).toInt(),
|
||||
) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
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 io.narl.protonstream.sync.NewEpisodesWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import io.narl.protonstream.download.DownloadCoordinator
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.EdgedButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
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,
|
||||
/** Resolves to why the settings were not stored, or null once they are. */
|
||||
onSaveMetadata: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
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 newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
|
||||
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||
// Playback preferences are the shared store's, not this app's: they are the
|
||||
// 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
|
||||
}
|
||||
SettingSwitch(
|
||||
"New episode notifications",
|
||||
"Looks at your shares every few hours and says when a show gains episodes",
|
||||
newEpisodes,
|
||||
) {
|
||||
newEpisodes = it
|
||||
settings.newEpisodeNotifications = it
|
||||
NewEpisodesWorker.schedule(context)
|
||||
}
|
||||
}
|
||||
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||
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 = {
|
||||
DangerButton(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 = onSaveMetadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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: suspend (Boolean, MetadataProvider, String, String) -> String?,
|
||||
) {
|
||||
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("") }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
|
||||
val forgets = current.enabled && (!enabled || provider != current.provider)
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!saving) 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, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
|
||||
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).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, and television from TVmaze; no account or API key required. " +
|
||||
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
|
||||
} 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(
|
||||
when {
|
||||
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
|
||||
current.ready -> "TMDB API key (leave blank to keep)"
|
||||
else -> "TMDB API key"
|
||||
},
|
||||
)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||
// Said before Save rather than after: what is forgotten
|
||||
// includes matches chosen by hand, which no look-up redoes.
|
||||
if (forgets) {
|
||||
Text(
|
||||
"Saving forgets every poster, description and match stored now, " +
|
||||
"including ones you chose by hand.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
enabled = !needsKey && !saving,
|
||||
onClick = {
|
||||
saving = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onSave(enabled, provider, language.ifBlank { "en" }, apiKey)
|
||||
saving = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !saving) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||
MetadataProvider.ANI_LIST -> "AniList"
|
||||
MetadataProvider.TMDB -> "TMDB"
|
||||
MetadataProvider.TV_MAZE -> "TVmaze"
|
||||
}
|
||||
|
||||
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,433 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.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.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.DangerButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
internal fun SharesScreen(
|
||||
shares: List<ShareRecord>,
|
||||
/** Resolves to why the link was refused, or null once it is added. */
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
onRepair: (String, String, String?) -> Unit,
|
||||
onRefresh: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
incomingLink: String?,
|
||||
onIncomingLinkTaken: () -> Unit,
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
onRename: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
var browsing by remember { mutableStateOf(false) }
|
||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
TabPage("Shares", padding) { body ->
|
||||
Box(Modifier.fillMaxSize().padding(body)) {
|
||||
if (shares.isEmpty() && account.state == null) {
|
||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||
} else {
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
if (accountActions != null) {
|
||||
item(key = "account") {
|
||||
AccountCard(
|
||||
account,
|
||||
accountActions,
|
||||
onSignIn = { signingIn = true },
|
||||
onBrowse = { browsing = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shares.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(shares, key = { it.id }) { share ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
onRefresh = { onRefresh(share.id) },
|
||||
onRepair = { repairing = share },
|
||||
onRemove = { removing = share },
|
||||
onRename = { renaming = share },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where a thumb is, and the one thing this page is for. The share
|
||||
// sheet and a tapped link land in the same form.
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAdd = true },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
text = { Text("Add share") },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showAdd || incomingLink != null) {
|
||||
AddShareDialog(
|
||||
initialUrl = incomingLink.orEmpty(),
|
||||
onDismiss = { showAdd = false; onIncomingLinkTaken() },
|
||||
onAdd = onAdd,
|
||||
)
|
||||
}
|
||||
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
|
||||
account.state == AccountState.MailboxPassword)
|
||||
) {
|
||||
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
|
||||
}
|
||||
if (accountActions != null && browsing) {
|
||||
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
|
||||
}
|
||||
repairing?.let { share ->
|
||||
RepairShareDialog(
|
||||
share = share,
|
||||
onDismiss = { repairing = null },
|
||||
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||
)
|
||||
}
|
||||
renaming?.let { share ->
|
||||
var name by remember(share.id) { mutableStateOf(share.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Rename ${share.name}") },
|
||||
text = { OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true) },
|
||||
confirmButton = {
|
||||
AccentButton(enabled = name.isNotBlank(), onClick = {
|
||||
renaming = null
|
||||
onRename(share.id, name.trim())
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = { renaming = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
removing?.let { share ->
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
AlertDialog(
|
||||
onDismissRequest = { removing = null },
|
||||
title = { Text("Remove ${share.name}?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its titles leave the library and its offline episodes are deleted. " +
|
||||
"Watch progress is kept, so adding the link again picks up where you left off.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
DangerButton(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.
|
||||
*/
|
||||
/** "120 files · crawled 2 h ago", or that it has not been yet. */
|
||||
internal fun crawlLine(share: ShareRecord, now: Long = System.currentTimeMillis() / 1_000): String {
|
||||
val at = share.crawledAt ?: return "Not crawled yet"
|
||||
val files = share.crawlFiles?.toLong()?.let { if (it == 1L) "1 file" else "$it files" }
|
||||
return listOfNotNull(files, "crawled ${ago(at, now)}").joinToString(" · ")
|
||||
}
|
||||
|
||||
/** "just now", "5 min ago", "3 h ago", "2 days ago" — the desktop's `ui::ago`. */
|
||||
internal fun ago(at: Long, now: Long): String {
|
||||
val seconds = (now - at).coerceAtLeast(0)
|
||||
return when {
|
||||
seconds < 60 -> "just now"
|
||||
seconds < 3_600 -> "${seconds / 60} min ago"
|
||||
seconds < 86_400 -> "${seconds / 3_600} h ago"
|
||||
seconds < 172_800 -> "1 day ago"
|
||||
else -> "${seconds / 86_400} days ago"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareRow(
|
||||
share: ShareRecord,
|
||||
onRefresh: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onRename: () -> Unit = {},
|
||||
) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
Text(crawlLine(share), style = MaterialTheme.typography.bodySmall)
|
||||
// Until a crawl succeeds: a link revoked upstream otherwise
|
||||
// just stops growing.
|
||||
share.crawlError?.let {
|
||||
Text(
|
||||
"Last crawl failed: $it",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
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() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
|
||||
onClick = { menu = false; onRename() },
|
||||
)
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
if (!share.fromAccount) {
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a link. The form stays up while the link is opened, and with the reason
|
||||
* under it when the link is refused, so fixing a typo is a retry rather than
|
||||
* starting over.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddShareDialog(
|
||||
initialUrl: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: suspend (String, String, String?) -> String?,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||
var showLink by remember { mutableStateOf(false) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var refusal by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!adding) onDismiss() },
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Add Proton Drive share") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
url,
|
||||
{ url = it },
|
||||
label = { Text("Share link") },
|
||||
singleLine = true,
|
||||
// The URL fragment is a secret. Password input disables
|
||||
// IME learning/suggestions while normal long-press paste
|
||||
// remains available; the eye shows what was pasted.
|
||||
visualTransformation = if (showLink) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showLink = !showLink }) {
|
||||
Icon(
|
||||
if (showLink) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showLink) "Hide link" else "Show link",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
name,
|
||||
{ name = it },
|
||||
label = { Text("Name (optional)") },
|
||||
supportingText = { Text("Defaults to the shared folder's name") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
password,
|
||||
{ password = it },
|
||||
label = { Text("Custom password (optional)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
if (adding) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Text("Opening the link…")
|
||||
}
|
||||
} else {
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
AccentButton(
|
||||
onClick = {
|
||||
adding = true
|
||||
refusal = null
|
||||
scope.launch {
|
||||
refusal = onAdd(name.trim(), url.trim(), password)
|
||||
adding = false
|
||||
if (refusal == null) onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = url.isNotBlank() && !adding,
|
||||
) { Text(if (adding) "Adding…" else "Add") }
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = onDismiss, enabled = !adding) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
@@ -18,14 +20,18 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
|
||||
import androidx.compose.material3.SliderState
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -68,17 +74,30 @@ internal fun AccentButton(
|
||||
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,
|
||||
)
|
||||
// The button pads itself out to the 48dp minimum touch target, and a
|
||||
// background on its modifier fills that whole target — 8dp taller than
|
||||
// the 40dp container every other button draws. So the padding is taken
|
||||
// here, outside the ramp, and the button inside is told not to add its
|
||||
// own. The box hands its size down, so a `fillMaxWidth` still reaches it.
|
||||
Box(
|
||||
modifier.minimumInteractiveComponentSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
// Only while it is live: a disabled button keeps Material's flat
|
||||
// disabled container, because a greyed control that still wears the
|
||||
// one strong colour in the window reads as pressable.
|
||||
modifier = if (enabled) Modifier.background(accentBrush(), shape) else Modifier,
|
||||
enabled = enabled,
|
||||
shape = shape,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -123,6 +142,28 @@ internal fun QuietButton(
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* The yes of a confirmation that deletes or forgets something.
|
||||
*
|
||||
* Not [AccentButton]: the filled accent is this app's "go on", and a dialog
|
||||
* whose most inviting button is the one that loses something reads as the
|
||||
* safe choice. Error-toned text instead, as Material's own guidance has it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DangerButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* How far along something is, drawn in the accent.
|
||||
*
|
||||
@@ -168,11 +209,21 @@ internal fun AccentProgress(
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
|
||||
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)
|
||||
|
||||
@@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes(
|
||||
/** The radius `pstr-app` draws everything at. */
|
||||
internal const val CORNER = 8f
|
||||
|
||||
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
|
||||
/** 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. */
|
||||
|
||||
@@ -56,6 +56,12 @@ object AppearanceState {
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Read the stored choice again, after sync rewrote it. */
|
||||
suspend fun reload() {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
|
||||
.onSuccess { current.value = it }
|
||||
}
|
||||
|
||||
/** Repaint immediately, before the write that stores the choice lands. */
|
||||
fun apply(palette: PaletteRecord) {
|
||||
current.value = palette
|
||||
|
||||
@@ -2,9 +2,35 @@ 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),
|
||||
)
|
||||
|
||||
/**
|
||||
* Inter Display Bold, the optical size drawn for large text, for the one
|
||||
* [HERO] line: the banner's name and the name at the top of a title's page.
|
||||
* The desktop's `theme::display` family, from the same file.
|
||||
*/
|
||||
internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold))
|
||||
|
||||
/**
|
||||
* The type ramp, which is the desktop client's type ramp.
|
||||
*
|
||||
@@ -14,7 +40,10 @@ import androidx.compose.ui.unit.sp
|
||||
* 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.
|
||||
* The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
|
||||
* The top one is the hero line, in [InterDisplay] and set tighter, at
|
||||
* [HERO_LEADING]: at that size the text face's leading pushes a two-line name
|
||||
* apart.
|
||||
*
|
||||
* 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
|
||||
@@ -25,7 +54,8 @@ import androidx.compose.ui.unit.sp
|
||||
* [TypeRampTest] pins every one of these against the Rust ramp.
|
||||
*/
|
||||
internal val ProtonTypography = Typography(
|
||||
displayLarge = role(DISPLAY, FontWeight.SemiBold),
|
||||
// The banner's name and the title page's name, where no logo stands in.
|
||||
displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING),
|
||||
displayMedium = role(DISPLAY, FontWeight.SemiBold),
|
||||
displaySmall = role(TITLE, FontWeight.SemiBold),
|
||||
// The name of the title you opened — the desktop's display line.
|
||||
@@ -50,6 +80,7 @@ internal val ProtonTypography = Typography(
|
||||
labelSmall = role(MICRO, FontWeight.Medium),
|
||||
)
|
||||
|
||||
internal const val HERO = 34f
|
||||
internal const val DISPLAY = 26f
|
||||
internal const val TITLE = 20f
|
||||
internal const val HEADING = 18f
|
||||
@@ -60,6 +91,9 @@ internal const val LABEL = 13f
|
||||
internal const val CAPTION = 12f
|
||||
internal const val MICRO = 11f
|
||||
|
||||
internal const val LEADING = 1.35f
|
||||
internal const val HERO_LEADING = 1.1f
|
||||
|
||||
/**
|
||||
* One rung as a `TextStyle`.
|
||||
*
|
||||
@@ -68,8 +102,14 @@ internal const val MICRO = 11f
|
||||
* 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(
|
||||
private fun role(
|
||||
size: Float,
|
||||
weight: FontWeight,
|
||||
family: FontFamily = Inter,
|
||||
leading: Float = LEADING,
|
||||
) = TextStyle(
|
||||
fontSize = size.sp,
|
||||
lineHeight = (size * 1.35f).sp,
|
||||
lineHeight = (size * leading).sp,
|
||||
fontWeight = weight,
|
||||
fontFamily = family,
|
||||
)
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -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"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
|
||||
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
|
||||
<group
|
||||
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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- The foreground's glyph as one flat shape, for themed launcher icons. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<group
|
||||
android:pivotX="70" android:pivotY="62"
|
||||
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||
android:translateX="-16" android:translateY="-8">
|
||||
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,13 +1,16 @@
|
||||
<!--
|
||||
The status-bar glyph. Android tints small icons to a single colour and drops
|
||||
everything that is not opaque, so this is the launcher mark reduced to one
|
||||
white silhouette rather than the two-tone drawable.
|
||||
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">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
|
||||
<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>
|
||||
@@ -1,3 +1,9 @@
|
||||
<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>
|
||||
@@ -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,37 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TransferRateTest {
|
||||
@Test
|
||||
fun `the first sample has no rate to report`() {
|
||||
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a steady transfer reports its rate`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
|
||||
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one slow block moves the rate without halving it`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
rate.sample(1_000_000, 1_000)
|
||||
val after = rate.sample(2_000_000, 3_000)
|
||||
assertTrue(after in 600_000L..999_999L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resumed download starting below the last sample starts over`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
rate.sample(1_000_000, 1_000)
|
||||
assertEquals(1_000_000L, rate.sample(0, 2_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AccountUiTest {
|
||||
@Test
|
||||
fun `the sync line says how long ago and what came in`() {
|
||||
val minute = 60_000L
|
||||
assertEquals("Library, settings and watch history sync through your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||
assertEquals(
|
||||
"Synced 2 h ago · 3 from other devices",
|
||||
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed sync is said in place of when it last worked`() {
|
||||
assertEquals(
|
||||
"Did not sync: offline",
|
||||
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the verification page may only navigate within Proton over https`() {
|
||||
assertTrue(isProtonPage("https", "verify.proton.me"))
|
||||
assertTrue(isProtonPage("https", "proton.me"))
|
||||
assertFalse(isProtonPage("http", "verify.proton.me"))
|
||||
assertFalse(isProtonPage("https", "proton.me.example.com"))
|
||||
assertFalse(isProtonPage("https", "notproton.me"))
|
||||
assertFalse(isProtonPage("https", null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ArtworkTest {
|
||||
@Test
|
||||
fun `initials skip the lower-case particles of a romanised name`() {
|
||||
assertEquals("OK", initials("Oshi no Ko"))
|
||||
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-word name has one initial`() {
|
||||
assertEquals("A", initials("Akira"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name with no capitals falls back to its first two words`() {
|
||||
assertEquals("BW", initials("blue world order"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `punctuation and separators are not words`() {
|
||||
assertEquals("S2", initials("Steins;Gate - 2"))
|
||||
assertEquals("", initials(" - "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
class EpisodeNameTest {
|
||||
private fun episode(name: String, detail: String, providerName: String? = null) = EpisodeRecord(
|
||||
shareId = "s", volumeId = "v", linkId = "l", name = name, label = "S01E01", detail = detail,
|
||||
season = 1u, number = 1u, size = null, progress = null, resumeAt = null,
|
||||
watched = false, offline = false, providerName = providerName, providerOverview = null, stillUrl = null,
|
||||
airDate = null, lastPlayed = 0,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the player captions an episode with its name and never its filename`() {
|
||||
val file = "[Group] Show - S01E01.mkv"
|
||||
assertEquals("Dog & Chainsaw", episode(file, file, providerName = "Dog & Chainsaw").shownName())
|
||||
assertEquals("Winter Is Coming", episode(file, "Winter Is Coming").shownName())
|
||||
assertNull(episode(file, file).shownName())
|
||||
assertNull(episode(file, "").shownName())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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(),
|
||||
continueIndex = null,
|
||||
)
|
||||
|
||||
@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,30 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LogoAndRuntimeTest {
|
||||
@Test
|
||||
fun `a padded logo is bounded by its lettering and not its haze`() {
|
||||
val width = 10
|
||||
val pixels = IntArray(width * 6)
|
||||
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
|
||||
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
|
||||
pixels[5 * width + 9] = 0x03FFFFFF
|
||||
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a logo with nothing visible has no bounds`() {
|
||||
assertNull(opaqueBounds(IntArray(16), 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running time reads in hours and minutes`() {
|
||||
assertEquals("24 min", formatRuntime(24u))
|
||||
assertEquals("2 h", formatRuntime(120u))
|
||||
assertEquals("2 h 4 min", formatRuntime(124u))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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.MetadataProvider
|
||||
import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.ShelfRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.TileRecord
|
||||
import uniffi.pstr_android.CastRecord
|
||||
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(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the library on a tablet`() = capture("library-tablet") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title in the light palette`() = captureLight("title-phone-light") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `a title on a tablet`() = capture("title-tablet") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the history page on a tablet`() = capture("history-tablet") {
|
||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||
}
|
||||
|
||||
@Test
|
||||
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(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, 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 }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the settings page`() = capture("settings-phone") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the playback settings`() = capture("settings-playback") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the storage settings`() = capture("settings-storage") {
|
||||
SettingsScreen(LIBRARY, { _, _, _, _ -> null }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||
}
|
||||
|
||||
@Test
|
||||
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(),
|
||||
// What the engine would pick for these fixtures: the part-watched episode.
|
||||
continueIndex = seasons.flatMap { it.episodes }.indexOfFirst { it.resumeAt != null }
|
||||
.takeIf { it >= 0 }?.toUInt(),
|
||||
)
|
||||
|
||||
val SERIES = title(
|
||||
"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"),
|
||||
trailerUrl = "https://www.youtube.com/watch?v=trailer",
|
||||
cast = listOf(
|
||||
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
|
||||
CastRecord("Aqua", null, "Takeo Ootsuka", null),
|
||||
),
|
||||
)
|
||||
|
||||
/** 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)
|
||||
|
||||
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
|
||||
val MATCHED get() = LIBRARY.copy(
|
||||
metadataSettings = MetadataSettingsRecord(
|
||||
enabled = true,
|
||||
provider = MetadataProvider.ANI_LIST,
|
||||
language = "en",
|
||||
ready = true,
|
||||
),
|
||||
)
|
||||
|
||||
val LIBRARY = AppUiState(
|
||||
loading = false,
|
||||
titles = listOf(
|
||||
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"))),
|
||||
),
|
||||
// Crawled relative to the clock, so the row reads "2 h ago" on
|
||||
// whatever day the test runs.
|
||||
shares = listOf(
|
||||
ShareRecord(
|
||||
"anime",
|
||||
"anime",
|
||||
true,
|
||||
crawledAt = System.currentTimeMillis() / 1_000 - 2 * 3_600,
|
||||
crawlFiles = 120uL,
|
||||
),
|
||||
ShareRecord("films", "films", false, crawlError = "the link was revoked or has expired"),
|
||||
),
|
||||
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.TitleRecord
|
||||
import uniffi.pstr_android.TitleType
|
||||
|
||||
class StatusTest {
|
||||
private fun title(status: String?, airing: Boolean = false, next: Pair<UInt, Long>? = null) = TitleRecord(
|
||||
key = "k", name = "k", year = null, kind = TitleType.SERIES, watchedCount = 0uL,
|
||||
episodeCount = 0uL, 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 = emptyList(),
|
||||
displayName = "k", wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(),
|
||||
directors = emptyList(), tags = emptyList(), airing = airing, nextEpisode = next?.first,
|
||||
nextAiringAt = next?.second, franchise = emptyList(), continueIndex = null, status = status,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the status chip names the next episode while one is due`() {
|
||||
val (text, live) = statusText(title("Airing", airing = true, next = 8u to 1_800_000_000L))!!
|
||||
assertTrue(text, text.startsWith("Airing · episode 8 "))
|
||||
assertTrue(live)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished title is said plainly and is not live`() {
|
||||
assertEquals("Finished" to false, statusText(title("Finished")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title with no status and nothing airing has no chip`() {
|
||||
assertNull(statusText(title(null)))
|
||||
assertEquals("Airing" to true, statusText(title(null, airing = true)))
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ import org.junit.Test
|
||||
*/
|
||||
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 ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
|
||||
private val expected = mapOf(
|
||||
"displayLarge" to 26f,
|
||||
"displayLarge" to 34f,
|
||||
"displayMedium" to 26f,
|
||||
"displaySmall" to 20f,
|
||||
"headlineLarge" to 26f,
|
||||
@@ -62,12 +62,14 @@ class TypeRampTest {
|
||||
/**
|
||||
* 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.
|
||||
* a number with no relation to its own size. The hero line is the one set
|
||||
* tighter.
|
||||
*/
|
||||
@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)
|
||||
val leading = if (name == "displayLarge") HERO_LEADING else LEADING
|
||||
assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 867 KiB |
|
After Width: | Height: | Size: 828 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -14,6 +14,7 @@ androidx-test-core = "1.7.0"
|
||||
androidx-test-junit = "1.3.0"
|
||||
androidx-test-runner = "1.7.0"
|
||||
ktlint-plugin = "13.1.0"
|
||||
roborazzi = "1.76.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||
@@ -41,9 +42,13 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
|
||||
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
||||
androidx-test-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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||
@@ -24,6 +24,7 @@ pstr-core.workspace = true
|
||||
pstr-meta.workspace = true
|
||||
pstr-stream.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
parking_lot.workspace = true
|
||||
thiserror.workspace = true
|
||||
uniffi.workspace = true
|
||||
|
||||
@@ -2,3 +2,9 @@
|
||||
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
||||
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||
disable_java_cleaner = true
|
||||
|
||||
[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,30 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
eframe.workspace = true
|
||||
egui.workspace = true
|
||||
egui-phosphor.workspace = true
|
||||
# The parser egui reads fonts with, to check a system font before handing it over.
|
||||
skrifa.workspace = true
|
||||
image.workspace = true
|
||||
sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
directories.workspace = true
|
||||
chrono.workspace = true
|
||||
souvlaki.workspace = true
|
||||
wry.workspace = true
|
||||
tao.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource.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 |
@@ -0,0 +1,15 @@
|
||||
//! Compiles `proton-stream.rc` into the Windows exe; nothing on any other
|
||||
//! target. `rc.exe` on an MSVC build, mingw's `windres` on the cross build
|
||||
//! from Linux.
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=proton-stream.rc");
|
||||
println!("cargo:rerun-if-changed=../../packaging/windows/proton-stream.ico");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
// Only the GUI gets the icon; `pstr` is a console tool.
|
||||
embed_resource::compile_for("proton-stream.rc", ["proton-stream"], embed_resource::NONE)
|
||||
.manifest_optional()
|
||||
.expect("compile the icon resource");
|
||||
}
|
||||
@@ -92,7 +92,12 @@ impl Harness {
|
||||
.context("eframe was built without a glow context")?;
|
||||
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
||||
// 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 {
|
||||
player,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// The exe's icon. The window sets one at runtime too, but that only lasts as
|
||||
// long as the window: Explorer, the Start menu and a taskbar pin read the icon
|
||||
// out of the file, and without this one they fall back to the generic one.
|
||||
1 ICON "../../packaging/windows/proton-stream.ico"
|
||||