Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d27ec996 | ||
|
|
66eb1b49a1 | ||
|
|
22425e0bad | ||
|
|
50481e6aed | ||
|
|
bedbe0ef52 | ||
|
|
7914cf4dc1 | ||
|
|
39a7f3cc38 | ||
|
|
630f07a462 | ||
|
|
9cba54df14 | ||
|
|
4ff8ae303c | ||
|
|
6c2ec83cb1 | ||
|
|
677ab2d928 | ||
|
|
fb32d6c850 | ||
|
|
110f97caf9 | ||
|
|
0563c91b9e | ||
|
|
3b9f4b549c | ||
|
|
d5c819ee94 | ||
|
|
153859a735 | ||
|
|
1c61aadb22 | ||
|
|
8d40b211ed | ||
|
|
6229a59561 | ||
|
|
cfd654b267 | ||
|
|
0bc7cba428 | ||
|
|
65ecc099b6 | ||
|
|
65a6ae9373 | ||
|
|
2fbbd0e090 | ||
|
|
eee45bc8f8 | ||
|
|
1c8df80c27 | ||
|
|
5673a44310 | ||
|
|
c63f08ac91 | ||
|
|
f2e69dc852 | ||
|
|
8a1807c262 | ||
|
|
47b33cdb91 | ||
|
|
2d7d895dda | ||
|
|
0d33799199 | ||
|
|
2ac906cf37 | ||
|
|
cf065fc5fe | ||
|
|
55ee412a1b | ||
|
|
2275df40a8 | ||
|
|
78ffafe21a |
No files matched your search
@@ -3,7 +3,15 @@ name: Android
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['v*']
|
# A `v*` tag reaches this workflow through release.yml, which calls it and
|
||||||
|
# publishes the signed APK. Triggering on the tag here as well would build
|
||||||
|
# the release twice.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
signed_release:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'android/**'
|
- 'android/**'
|
||||||
@@ -74,14 +82,23 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||||
|
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||||
|
# build needs are installed explicitly below.
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Install native build prerequisites
|
- name: Install native build prerequisites
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends \
|
sudo apt-get install --no-install-recommends \
|
||||||
autoconf automake build-essential cmake curl git gperf libtool \
|
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||||
|
# mpv-android revision builds requires >= 1.11.
|
||||||
|
pipx install meson==1.12.1
|
||||||
|
|
||||||
- name: Install Android SDK and NDK
|
- name: Install Android SDK and NDK
|
||||||
run: >-
|
run: >-
|
||||||
@@ -90,12 +107,53 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
|
# `release` tree under it before saving, so with its target caching on
|
||||||
|
# every run rebuilt both ABIs from scratch.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
with:
|
||||||
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# The target directory as is: the host build the UniFFI bindings come
|
||||||
|
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||||
|
# build compiles the same crates for the same targets. A new lockfile
|
||||||
|
# starts from the last one's tree, so a version bump rebuilds only the
|
||||||
|
# workspace crates.
|
||||||
|
- id: target
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
# libmpv and its dependency tree are the slowest part of the build, and
|
||||||
|
# depend only on the pinned revision and the build script, which stamps
|
||||||
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
|
# and saved in steps of its own, so a later failure in Gradle does not
|
||||||
|
# discard it the way a post-job cache save would.
|
||||||
|
- id: libmpv
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||||
|
|
||||||
|
- name: Build libmpv
|
||||||
|
run: bash scripts/build-libmpv-android.sh
|
||||||
|
|
||||||
|
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
@@ -124,9 +182,15 @@ jobs:
|
|||||||
- name: Android checks and artifacts
|
- name: Android checks and artifacts
|
||||||
run: bash scripts/build-android.sh all
|
run: bash scripts/build-android.sh all
|
||||||
|
|
||||||
|
- if: steps.target.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||||
|
|
||||||
signed-release:
|
signed-release:
|
||||||
name: Signed release
|
name: Signed release
|
||||||
if: false
|
if: startsWith(github.ref, 'refs/tags/v') || inputs.signed_release
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -141,14 +205,23 @@ jobs:
|
|||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
|
# The action's default `packages` includes `tools`, which cmdline-tools
|
||||||
|
# 16.0 no longer offers, so sdkmanager exits 1. The SDK pieces this
|
||||||
|
# build needs are installed explicitly below.
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
log-accepted-android-sdk-licenses: false
|
||||||
|
|
||||||
- name: Install native build prerequisites
|
- name: Install native build prerequisites
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends \
|
sudo apt-get install --no-install-recommends \
|
||||||
autoconf automake build-essential cmake curl git gperf libtool \
|
autoconf automake build-essential cmake curl git gperf libdbus-1-dev \
|
||||||
meson nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
libtool nasm ninja-build pkg-config python3 unzip wget xz-utils yasm
|
||||||
|
# Not apt's meson: noble ships 1.3.2, and the fontconfig that the pinned
|
||||||
|
# mpv-android revision builds requires >= 1.11.
|
||||||
|
pipx install meson==1.12.1
|
||||||
|
|
||||||
- name: Install Android SDK and NDK
|
- name: Install Android SDK and NDK
|
||||||
run: >-
|
run: >-
|
||||||
@@ -157,12 +230,53 @@ jobs:
|
|||||||
"build-tools;${ANDROID_BUILD_TOOLS}"
|
"build-tools;${ANDROID_BUILD_TOOLS}"
|
||||||
"ndk;${ANDROID_NDK}"
|
"ndk;${ANDROID_NDK}"
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- id: toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
# The registry and the installed helpers only. rust-cache reads
|
||||||
|
# `target/android/<triple>` as a profile directory and deletes the
|
||||||
|
# `release` tree under it before saving, so with its target caching on
|
||||||
|
# every run rebuilt both ABIs from scratch.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
with:
|
||||||
|
shared-key: android-registry
|
||||||
|
cache-targets: false
|
||||||
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# The target directory as is: the host build the UniFFI bindings come
|
||||||
|
# from and both cargo-ndk ABIs. One entry for both jobs, since the signed
|
||||||
|
# build compiles the same crates for the same targets. A new lockfile
|
||||||
|
# starts from the last one's tree, so a version bump rebuilds only the
|
||||||
|
# workspace crates.
|
||||||
|
- id: target
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: rust-target-android-${{ steps.toolchain.outputs.cachekey }}-
|
||||||
|
|
||||||
|
# libmpv and its dependency tree are the slowest part of the build, and
|
||||||
|
# depend only on the pinned revision and the build script, which stamps
|
||||||
|
# its output with both and skips the build when the stamp matches. Built
|
||||||
|
# and saved in steps of its own, so a later failure in Gradle does not
|
||||||
|
# discard it the way a post-job cache save would.
|
||||||
|
- id: libmpv
|
||||||
|
uses: actions/cache/restore@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: libmpv-${{ hashFiles('scripts/build-libmpv-android.sh') }}
|
||||||
|
|
||||||
|
- name: Build libmpv
|
||||||
|
run: bash scripts/build-libmpv-android.sh
|
||||||
|
|
||||||
|
- if: steps.libmpv.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: android/app/build/generated/mpv
|
||||||
|
key: ${{ steps.libmpv.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
@@ -188,10 +302,30 @@ jobs:
|
|||||||
- name: Build signed APK and AAB
|
- name: Build signed APK and AAB
|
||||||
run: bash scripts/build-android.sh signed
|
run: bash scripts/build-android.sh signed
|
||||||
|
|
||||||
|
- if: steps.target.outputs.cache-hit != 'true'
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
key: ${{ steps.target.outputs.cache-primary-key }}
|
||||||
|
|
||||||
|
# The APK bundles a GPL libmpv, so its corresponding source ships next to
|
||||||
|
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||||
|
- name: Stage release files
|
||||||
|
run: |
|
||||||
|
version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||||
|
mkdir -p dist/android
|
||||||
|
cp android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
"dist/android/proton-stream-$version-android.apk"
|
||||||
|
cp android/app/build/generated/mpv/mpv-android-source-*.tar.gz dist/android/
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-signed
|
name: android
|
||||||
path: |
|
path: dist/android/*
|
||||||
android/app/build/outputs/apk/release/*.apk
|
if-no-files-found: error
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-bundle
|
||||||
|
path: android/app/build/outputs/bundle/release/*.aab
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -20,9 +20,6 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Android remains a workflow artifact only until the native libmpv adapter,
|
|
||||||
# corresponding-source bundle, and on-device verification are complete.
|
|
||||||
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
@@ -60,11 +57,6 @@ jobs:
|
|||||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
||||||
|
|
||||||
- name: Android publication remains blocked
|
|
||||||
run: |
|
|
||||||
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
|
|
||||||
echo "Android APK/AAB publication is blocked until native playback is release-ready."
|
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
name: Linux artifacts
|
name: Linux artifacts
|
||||||
needs: verify
|
needs: verify
|
||||||
@@ -148,25 +140,38 @@ jobs:
|
|||||||
dist/*.msi
|
dist/*.msi
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The signed APK plus the corresponding source of the libmpv it bundles. The
|
||||||
|
# keystore and its passwords come from the ANDROID_RELEASE_* secrets.
|
||||||
|
android:
|
||||||
|
name: Android
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/android.yml
|
||||||
|
with:
|
||||||
|
signed_release: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish desktop artifacts
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows]
|
needs: [linux, windows, android]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
# By name: the Android workflow also uploads the AAB, which is for Play
|
||||||
|
# and does not belong on the release.
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
|
name: linux
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Refuse mobile artifacts while Android publishing is blocked
|
|
||||||
run: |
|
|
||||||
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
|
|
||||||
echo "::error::Android publishing is disabled while native playback remains incomplete"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4359,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4378,7 +4378,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4409,7 +4409,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4427,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4445,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4458,7 +4458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -4473,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Android plan
|
||||||
|
|
||||||
|
Where the Android client stands against the desktop app, and what to change to
|
||||||
|
make it feel like a modern streaming app rather than a settings form with a
|
||||||
|
player attached. Written 2026-10-03 from a source review and four screenshots
|
||||||
|
(Library, Title, Shares, Settings) taken on a phone running v0.5.0.
|
||||||
|
|
||||||
|
Android last caught up with the desktop on 2026-08-10 (`4ef6225`); the desktop
|
||||||
|
gained about twenty features on 2026-10-02 that Android does not have.
|
||||||
|
|
||||||
|
## Status (2026-10-03)
|
||||||
|
|
||||||
|
Done: section 0, every parity item, and every recommendation in section 2 —
|
||||||
|
the Library, Title, Shares, Settings and Downloads redesigns, per-page app
|
||||||
|
bars drawn edge to edge, search behind an icon, skeleton loading and
|
||||||
|
generated placeholder art, Settings sub-pages and a language picker, a
|
||||||
|
long-press Change match, Inter, light and tablet screenshots, the library
|
||||||
|
and a title side by side from 840 dp, a poster-to-backdrop shared element
|
||||||
|
transition, and predictive back on the title page.
|
||||||
|
|
||||||
|
Two departures from the text below. The tablet layout is a two-pane `Row`
|
||||||
|
rather than `ListDetailPaneScaffold`, which would have added the
|
||||||
|
`adaptive-layout` artifact for one split. Pull to refresh shows the
|
||||||
|
indicator while a crawl runs, but not the crawl's progress.
|
||||||
|
|
||||||
|
Not yet verified on a device; the host gate (ktlint, unit and screenshot
|
||||||
|
tests) passes, and Android lint runs in CI.
|
||||||
|
|
||||||
|
## 0. CI (done)
|
||||||
|
|
||||||
|
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||||
|
`pstr-core` pulls `keyring` → `dbus-secret-service` → `libdbus-sys`, which needs
|
||||||
|
`libdbus-1-dev` on the host. Because no run ever succeeded, no cache was ever
|
||||||
|
saved, so each run started from nothing.
|
||||||
|
|
||||||
|
- `libdbus-1-dev` added to both jobs' apt install.
|
||||||
|
- `Swatinem/rust-cache` shares one key between the two jobs and saves on failure.
|
||||||
|
- `android/app/build/generated/mpv` is cached on the hash of
|
||||||
|
`scripts/build-libmpv-android.sh`; the script writes a `STAMP` (revision plus
|
||||||
|
its own hash) last and skips the whole libmpv build when the stamp matches.
|
||||||
|
|
||||||
|
Later runs also needed meson 1.12.1 from PyPI (noble's 1.3.2 is too old for
|
||||||
|
the pinned fontconfig), libmpv built and cached in its own steps so a later
|
||||||
|
failure keeps it, and lint's `OldTargetApi` made informational. A cached run
|
||||||
|
restores libmpv in under a minute.
|
||||||
|
|
||||||
|
## 1. Desktop parity
|
||||||
|
|
||||||
|
Ordered by value on a phone.
|
||||||
|
|
||||||
|
1. **Open share links from outside the app.** The manifest has only the
|
||||||
|
launcher intent. Add an `ACTION_VIEW` filter for `drive.proton.me/urls/*` and
|
||||||
|
an `ACTION_SEND` (`text/plain`) filter; both open the add-share dialog with
|
||||||
|
the link filled in. Desktop: `4a54af3`, `53a1d0a`.
|
||||||
|
2. **Streaming cache budget.** Android runs on the fixed 4 GiB default
|
||||||
|
(`DiskCacheConfig::DEFAULT_BUDGET_BYTES`), large for a phone. Expose
|
||||||
|
`DiskCache::set_budget` through `pstr-android`, add a 1–16 GiB choice with a
|
||||||
|
usage readout under Storage. Desktop: `a432a93`.
|
||||||
|
3. **Hardware-decoding switch.** `pstr_mpv.cpp` hard-codes
|
||||||
|
`hwdec=auto-copy-safe`; a device with green frames has no way out. A
|
||||||
|
Settings toggle, applied from the next file.
|
||||||
|
4. **Episode list in the player.** A bottom sheet (portrait) or side panel
|
||||||
|
(landscape) with the title's episodes, opened on the playing season and
|
||||||
|
scrolled to the playing episode. Desktop: `7e734db`.
|
||||||
|
5. **Long-press menu on tiles.** Play/resume, mark watched or unwatched,
|
||||||
|
download, change match, remove from Continue watching, in a
|
||||||
|
`ModalBottomSheet`. Desktop: `c5808a5`.
|
||||||
|
6. **Undo on snackbars** for marking a title and leaving Continue watching.
|
||||||
|
`AppUiState.message` is a bare string today; it needs an action. Desktop:
|
||||||
|
`79c3f68`.
|
||||||
|
7. **History tab.** Needs a bridge call over the `pstr-core::library` query
|
||||||
|
the desktop page uses. Desktop: `e7eeaa5`.
|
||||||
|
8. **Buffered range on the seek bar** from mpv's `demuxer-cache-time`.
|
||||||
|
9. **Up-next card** with the next episode's still and a countdown ring instead
|
||||||
|
of "Up next in 7s" text.
|
||||||
|
10. **Featured banner** at the top of the library. Desktop: `7d03736`.
|
||||||
|
11. **Bundled Inter** in `ui/theme/Type.kt`, so both clients read the same.
|
||||||
|
Desktop: `558446b`.
|
||||||
|
|
||||||
|
## 2. UI recommendations
|
||||||
|
|
||||||
|
### Everywhere
|
||||||
|
|
||||||
|
- **Colours stay the theme's.** The red is the Persona theme's accent and the
|
||||||
|
card tint comes from the shared palette, so the redesign changes layout and
|
||||||
|
button hierarchy, not colours.
|
||||||
|
- **One button hierarchy.** At most one filled accent button per screen;
|
||||||
|
secondary actions tonal or outlined; destructive actions behind a menu and a
|
||||||
|
confirmation, never a filled red button in a list row.
|
||||||
|
- **Edge-to-edge, per-screen titles.** The "proton-stream" app bar on every
|
||||||
|
page wastes a band of height. Library uses a collapsing large title; Title
|
||||||
|
pages draw the backdrop under the status bar; other tabs use their name.
|
||||||
|
- **Skeletons instead of spinners** for library and title loading, and
|
||||||
|
generated placeholder art (gradient from the accent plus the title's
|
||||||
|
initials) instead of a black card with a play triangle (Akira).
|
||||||
|
- **Pull-to-refresh** (`PullToRefreshBox`) replaces the top-bar refresh icon,
|
||||||
|
with crawl progress shown in the indicator area.
|
||||||
|
- **Split `ui/ProtonStreamApp.kt`** (1665 lines) into one file per screen
|
||||||
|
before the work below; every item touches it.
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
- Poster grid (2:3, `posterUrl`) at three columns on a phone, title under the
|
||||||
|
poster with no card body. The current 210 dp cropped backdrops fit two titles
|
||||||
|
per screen.
|
||||||
|
- Continue watching: 16:9 still with the progress bar laid over the bottom
|
||||||
|
edge of the image and "S03E11 · 12 min left" under it.
|
||||||
|
- Search becomes an icon in the app bar that expands into an M3 `SearchBar`,
|
||||||
|
instead of a permanent field above everything.
|
||||||
|
- Fix "1 episodes" (line ~507; the season header already pluralises).
|
||||||
|
|
||||||
|
### Title page
|
||||||
|
|
||||||
|
- Full-bleed backdrop with a gradient scrim, poster, title and meta chips
|
||||||
|
(year, score, genres) over it, collapsing as the page scrolls.
|
||||||
|
- Synopsis clamped to three lines with "More".
|
||||||
|
- One full-width primary button, "Resume S03E11" or "Play S01E01", then a row
|
||||||
|
of icon actions: download, mark watched, and a ⋮ menu holding Start over,
|
||||||
|
Change match and More on AniList. Today five buttons of three styles wrap
|
||||||
|
over three lines.
|
||||||
|
- Drop the "Preferred tracks" form and its Save button from this page. The
|
||||||
|
player already remembers tracks by name per title (`d3c5f07`); expose any
|
||||||
|
override from the player's track chooser.
|
||||||
|
- Season picker as a dropdown chip or tabs, not stacked accordions; a 40
|
||||||
|
episode title is one long scroll today.
|
||||||
|
- Episode rows: 16:9 still with progress overlay, "1 · Mother and Children"
|
||||||
|
(provider name, else "Episode 1") rather than the raw file name
|
||||||
|
`[Judas] Oshi no Ko - S01E01.mkv`, runtime or air date as supporting text,
|
||||||
|
watched and download state as small trailing icons. The red `S01E01` label
|
||||||
|
above each row goes.
|
||||||
|
|
||||||
|
### Shares
|
||||||
|
|
||||||
|
- `ListItem` rows: name, "12 titles · crawled 2 h ago" or the failure, and a ⋮
|
||||||
|
menu with Refresh, Re-enter link and Remove (confirmed). The current row
|
||||||
|
wraps "Custom password stored securely" over three lines beside three
|
||||||
|
differently styled actions.
|
||||||
|
- "Add share" as an extended FAB, which is also where the share-link intent
|
||||||
|
from parity item 1 lands.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- Grouped `ListItem` rows (headline, one line of supporting text, trailing
|
||||||
|
switch), with the long explanations moved into the supporting line or an
|
||||||
|
info dialog. Today each toggle carries a paragraph.
|
||||||
|
- Split into sub-pages once storage and decoding settings land: Playback,
|
||||||
|
Downloads & storage, Appearance, Metadata, About.
|
||||||
|
- Language fields become a picker dialog over a language list, not free text.
|
||||||
|
- Storage shows a usage bar (cache, offline episodes) above the budget choice.
|
||||||
|
|
||||||
|
### Robustness
|
||||||
|
|
||||||
|
- Screenshot tests (Roborazzi, JVM, no device) for each screen in light and
|
||||||
|
dark, phone and tablet, at font scale 1.0 and 1.5 — the redesign changes
|
||||||
|
every screen, and `docs/TESTING.md` has no visual layer today.
|
||||||
|
- 48 dp minimum touch targets and content descriptions on the new icon-only
|
||||||
|
actions.
|
||||||
|
- Tablet: `ListDetailPaneScaffold` for library + title, since
|
||||||
|
`NavigationSuiteScaffold` already switches to a rail there.
|
||||||
|
- Shared-element transition from a poster to its title page, and predictive
|
||||||
|
back.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. CI green (section 0).
|
||||||
|
2. Split `ProtonStreamApp.kt`; add screenshot tests for the current screens.
|
||||||
|
3. Parity 1–3 (small, phone-specific fixes).
|
||||||
|
4. Button hierarchy and Inter — every later screen inherits them.
|
||||||
|
5. Title page, then Library, then Shares and Settings.
|
||||||
|
6. Parity 4–10.
|
||||||
@@ -12,6 +12,7 @@ distributions.
|
|||||||
| Kotlin | Apache-2.0 |
|
| Kotlin | Apache-2.0 |
|
||||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
| UniFFI | MPL-2.0 |
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
| proton-stream shared Rust crates | MIT |
|
| proton-stream shared Rust crates | MIT |
|
||||||
|
|
||||||
This summary is not a replacement for dependency license files. Before a
|
This summary is not a replacement for dependency license files. Before a
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.ktlint)
|
alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.roborazzi)
|
||||||
}
|
}
|
||||||
|
|
||||||
val repositoryRoot = rootProject.projectDir.parentFile
|
val repositoryRoot = rootProject.projectDir.parentFile
|
||||||
@@ -142,6 +143,9 @@ android {
|
|||||||
"GradleDependency",
|
"GradleDependency",
|
||||||
"NewerVersionAvailable",
|
"NewerVersionAvailable",
|
||||||
"AndroidGradlePluginVersion",
|
"AndroidGradlePluginVersion",
|
||||||
|
// The same for the platform: raising targetSdk opts into new
|
||||||
|
// behaviour and needs its own pass on a device.
|
||||||
|
"OldTargetApi",
|
||||||
)
|
)
|
||||||
sarifReport = true
|
sarifReport = true
|
||||||
}
|
}
|
||||||
@@ -176,20 +180,16 @@ val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi")
|
|||||||
val hostRustLibrary = repositoryRoot.resolve(
|
val hostRustLibrary = repositoryRoot.resolve(
|
||||||
"target/debug/${System.mapLibraryName("pstr_android")}",
|
"target/debug/${System.mapLibraryName("pstr_android")}",
|
||||||
)
|
)
|
||||||
val stagedMpvDir = layout.buildDirectory.dir("generated/mpv")
|
|
||||||
|
|
||||||
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
val buildLibmpvAndroid by tasks.registering(Exec::class) {
|
||||||
group = "native"
|
group = "native"
|
||||||
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
description = "Builds the pinned GPL libmpv and stages both Android ABIs."
|
||||||
workingDir(repositoryRoot)
|
workingDir(repositoryRoot)
|
||||||
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath)
|
||||||
inputs.file(repositoryRoot.resolve("scripts/build-libmpv-android.sh"))
|
// No declared outputs: the script's STAMP is the up-to-date check. Gradle
|
||||||
outputs.files(
|
// deletes the declared outputs of a task it has no history for, which on a
|
||||||
stagedMpvDir.map { it.file("jniLibs/arm64-v8a/libmpv.so") },
|
// fresh CI checkout threw away the tree just restored from the cache and
|
||||||
stagedMpvDir.map { it.file("jniLibs/x86_64/libmpv.so") },
|
// rebuilt libmpv from source on every run.
|
||||||
stagedMpvDir.map { it.file("include/mpv/client.h") },
|
|
||||||
stagedMpvDir.map { it.file("REVISION") },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val buildRustHost by tasks.registering(Exec::class) {
|
val buildRustHost by tasks.registering(Exec::class) {
|
||||||
@@ -269,6 +269,11 @@ dependencies {
|
|||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.androidx.test.core)
|
testImplementation(libs.androidx.test.core)
|
||||||
testImplementation(libs.androidx.work.testing)
|
testImplementation(libs.androidx.work.testing)
|
||||||
|
testImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
testImplementation(libs.roborazzi)
|
||||||
|
testImplementation(libs.roborazzi.compose)
|
||||||
|
testImplementation(libs.roborazzi.junit.rule)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.test.junit)
|
androidTestImplementation(libs.androidx.test.junit)
|
||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
@@ -292,6 +297,13 @@ ktlint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot baselines live beside the tests that draw them, so a change to how
|
||||||
|
// a screen looks shows up in review as a changed image. `recordRoborazziDebug`
|
||||||
|
// rewrites them; `verifyRoborazziDebug` fails on any difference.
|
||||||
|
roborazzi {
|
||||||
|
outputDir.set(file("src/test/screenshots"))
|
||||||
|
}
|
||||||
|
|
||||||
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
// The generated UniFFI directory is a main source dir, so ktlint reads it even
|
||||||
// though the filter above drops every file in it. Without this, Gradle refuses
|
// though the filter above drops every file in it. Without this, Gradle refuses
|
||||||
// the build for an undeclared dependency between the two tasks.
|
// the build for an undeclared dependency between the two tasks.
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
android:name=".ProtonStreamApplication"
|
android:name=".ProtonStreamApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@drawable/ic_launcher_foreground"
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
tools:targetApi="33"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ProtonStream">
|
android:theme="@style/Theme.ProtonStream">
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/launcher_name"
|
||||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|uiMode|density|fontScale|locale|layoutDirection|keyboardHidden"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
@@ -38,6 +41,24 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!--
|
||||||
|
Proton Drive public links. autoVerify="false", said explicitly:
|
||||||
|
the domain is Proton's, so this can never be a verified App Link.
|
||||||
|
Android only opens these here once "Open supported links" is
|
||||||
|
allowed in the app's settings; sharing a link to the app works
|
||||||
|
without that.
|
||||||
|
-->
|
||||||
|
<intent-filter android:autoVerify="false">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="drive.proton.me" android:pathPrefix="/urls/" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -11,6 +11,7 @@ complete GNU GPL version 3 text is packaged beside this file as `GPL-3.0.txt`.
|
|||||||
| Kotlin | Apache-2.0 |
|
| Kotlin | Apache-2.0 |
|
||||||
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
| JNA | Apache-2.0 or LGPL-2.1-or-later |
|
||||||
| UniFFI | MPL-2.0 |
|
| UniFFI | MPL-2.0 |
|
||||||
|
| Inter typeface (bundled, `Inter-OFL.txt`) | OFL-1.1 |
|
||||||
| proton-stream reusable Rust crates | MIT |
|
| proton-stream reusable Rust crates | MIT |
|
||||||
|
|
||||||
Before release, replace this dependency summary with an inventory generated
|
Before release, replace this dependency summary with an inventory generated
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ extern "C" {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char *kProtocol = "pstr";
|
constexpr const char *kProtocol = "pstr";
|
||||||
|
/// mpv's `hwdec` when hardware decoding is on. Why this value: `Player()`.
|
||||||
|
constexpr const char *kHardwareDecoding = "auto-copy-safe";
|
||||||
|
|
||||||
struct StreamCookie {
|
struct StreamCookie {
|
||||||
uint64_t handle;
|
uint64_t handle;
|
||||||
@@ -191,6 +193,9 @@ struct PlaybackState {
|
|||||||
/// Between a seek being issued and playback actually resuming. The seek bar
|
/// Between a seek being issued and playback actually resuming. The seek bar
|
||||||
/// has moved but the picture has not.
|
/// has moved but the picture has not.
|
||||||
bool seeking = false;
|
bool seeking = false;
|
||||||
|
/// How far into the file the demuxer has read ahead, in seconds; what the
|
||||||
|
/// seek bar shades, so a jump into it is known to cost no fetch.
|
||||||
|
double cached_until = 0.0;
|
||||||
/// True between `loadfile` being issued and mpv acknowledging it with
|
/// True between `loadfile` being issued and mpv acknowledging it with
|
||||||
/// `START_FILE`. Everything mpv reports in that window still describes the
|
/// `START_FILE`. Everything mpv reports in that window still describes the
|
||||||
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
|
/// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits —
|
||||||
@@ -216,6 +221,7 @@ class Player {
|
|||||||
option("vo", "libmpv");
|
option("vo", "libmpv");
|
||||||
option("force-window", "no");
|
option("force-window", "no");
|
||||||
option("video-timing-offset", "0");
|
option("video-timing-offset", "0");
|
||||||
|
// The default; `load` sets it per file from the viewer's setting.
|
||||||
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
// `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
|
||||||
// an `ANativeWindow` the decoder is handed at init, and this player has
|
// an `ANativeWindow` the decoder is handed at init, and this player has
|
||||||
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
|
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
|
||||||
@@ -224,7 +230,7 @@ class Player {
|
|||||||
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
// `hevc_mediacodec: Both surface and native_window are NULL` before
|
||||||
// falling back. The copy variants decode in hardware and hand back
|
// falling back. The copy variants decode in hardware and hand back
|
||||||
// ordinary frames, which is what this render path can actually use.
|
// ordinary frames, which is what this render path can actually use.
|
||||||
option("hwdec", "auto-copy-safe");
|
option("hwdec", kHardwareDecoding);
|
||||||
option("cache", "yes");
|
option("cache", "yes");
|
||||||
option("cache-secs", "30");
|
option("cache-secs", "30");
|
||||||
option("demuxer-readahead-secs", "30");
|
option("demuxer-readahead-secs", "30");
|
||||||
@@ -245,6 +251,7 @@ class Player {
|
|||||||
observe("video-params/dh", 7, MPV_FORMAT_INT64);
|
observe("video-params/dh", 7, MPV_FORMAT_INT64);
|
||||||
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
|
observe("paused-for-cache", 8, MPV_FORMAT_FLAG);
|
||||||
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
|
observe("cache-buffering-state", 9, MPV_FORMAT_INT64);
|
||||||
|
observe("demuxer-cache-time", 10, MPV_FORMAT_DOUBLE);
|
||||||
// Errors mpv reports about the file itself, rather than about the
|
// Errors mpv reports about the file itself, rather than about the
|
||||||
// request that opened it. Without these a failed demux is silent.
|
// request that opened it. Without these a failed demux is silent.
|
||||||
mpv_request_log_messages(mpv_, "error");
|
mpv_request_log_messages(mpv_, "error");
|
||||||
@@ -294,8 +301,12 @@ class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool load(uint64_t handle, double start, const std::string &audio,
|
bool load(uint64_t handle, double start, const std::string &audio,
|
||||||
const std::string &subtitle, bool subtitles) {
|
const std::string &subtitle, bool subtitles,
|
||||||
|
bool hardware_decoding) {
|
||||||
if (!mpv_) return false;
|
if (!mpv_) return false;
|
||||||
|
// Per file, before `loadfile`: a device whose decoder shows green
|
||||||
|
// frames is fixed from the next episode without restarting the core.
|
||||||
|
set_string("hwdec", hardware_decoding ? kHardwareDecoding : "no");
|
||||||
// No wait for a render context. It is created with the render thread and
|
// No wait for a render context. It is created with the render thread and
|
||||||
// needs no window, and a load that cannot get one plays audio anyway —
|
// needs no window, and a load that cannot get one plays audio anyway —
|
||||||
// waiting for a surface here is what made background playback fail after
|
// waiting for a surface here is what made background playback fail after
|
||||||
@@ -591,6 +602,7 @@ class Player {
|
|||||||
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
|
else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
|
else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast<int *>(property.data);
|
||||||
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
|
else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast<double>(*static_cast<int64_t *>(property.data));
|
||||||
|
else if (!std::strcmp(property.name, "demuxer-cache-time")) state_.cached_until = *static_cast<double *>(property.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void render_update(void *opaque) noexcept {
|
static void render_update(void *opaque) noexcept {
|
||||||
@@ -822,7 +834,7 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo
|
|||||||
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept {
|
||||||
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
c_boundary([=] { if (auto *p = from(handle)) p->detach(); });
|
||||||
}
|
}
|
||||||
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles) noexcept {
|
extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles, jboolean hardware_decoding) noexcept {
|
||||||
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
return c_boundary<jboolean>(JNI_FALSE, [=] {
|
||||||
std::string audio_text;
|
std::string audio_text;
|
||||||
std::string subtitle_text;
|
std::string subtitle_text;
|
||||||
@@ -832,7 +844,8 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM
|
|||||||
auto *p = from(handle);
|
auto *p = from(handle);
|
||||||
return static_cast<jboolean>(
|
return static_cast<jboolean>(
|
||||||
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
p && p->load(static_cast<uint64_t>(stream), start, audio_text,
|
||||||
subtitle_text, subtitles == JNI_TRUE)
|
subtitle_text, subtitles == JNI_TRUE,
|
||||||
|
hardware_decoding == JNI_TRUE)
|
||||||
? JNI_TRUE
|
? JNI_TRUE
|
||||||
: JNI_FALSE);
|
: JNI_FALSE);
|
||||||
});
|
});
|
||||||
@@ -868,8 +881,8 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat
|
|||||||
state.video_width, state.video_height,
|
state.video_width, state.video_height,
|
||||||
static_cast<jdouble>(state.end_reason),
|
static_cast<jdouble>(state.end_reason),
|
||||||
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
state.buffering ? 1.0 : 0.0, state.cache_percent,
|
||||||
state.seeking ? 1.0 : 0.0};
|
state.seeking ? 1.0 : 0.0, state.cached_until};
|
||||||
constexpr jsize kFields = 13;
|
constexpr jsize kFields = 14;
|
||||||
jdoubleArray result = env->NewDoubleArray(kFields);
|
jdoubleArray result = env->NewDoubleArray(kFields);
|
||||||
if (!result) return static_cast<jdoubleArray>(nullptr);
|
if (!result) return static_cast<jdoubleArray>(nullptr);
|
||||||
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
env->SetDoubleArrayRegion(result, 0, kFields, values);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Proton Drive public link, wherever in a piece of text it sits.
|
||||||
|
*
|
||||||
|
* The fragment is kept: it is the share's secret, and a link without it cannot
|
||||||
|
* be opened. Matching stops at whitespace and at the quotes and brackets a
|
||||||
|
* message app wraps a link in, which never occur in a link itself.
|
||||||
|
*/
|
||||||
|
private val SHARE_LINK = Regex("""https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+""")
|
||||||
|
|
||||||
|
/** The share link an `ACTION_VIEW` URL or an `ACTION_SEND` text carries, if any. */
|
||||||
|
internal fun shareLinkFrom(action: String?, url: String?, text: CharSequence?): String? = when (action) {
|
||||||
|
Intent.ACTION_VIEW -> url?.let { SHARE_LINK.matchEntire(it)?.value }
|
||||||
|
Intent.ACTION_SEND -> text?.let { SHARE_LINK.find(it)?.value }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Intent.shareLink(): String? =
|
||||||
|
shareLinkFrom(action, dataString, getCharSequenceExtra(Intent.EXTRA_TEXT))
|
||||||
@@ -48,6 +48,9 @@ import uniffi.pstr_android.PaletteRecord
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||||
private val inPictureInPicture = mutableStateOf(false)
|
private val inPictureInPicture = mutableStateOf(false)
|
||||||
|
|
||||||
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
||||||
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
||||||
/**
|
/**
|
||||||
* Whether a bind is outstanding, which is what has to be unbound.
|
* Whether a bind is outstanding, which is what has to be unbound.
|
||||||
*
|
*
|
||||||
@@ -97,6 +100,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
// Infallible by construction — an unreadable theme file resolves to the
|
// Infallible by construction — an unreadable theme file resolves to the
|
||||||
// shipped default rather than throwing.
|
// shipped default rather than throwing.
|
||||||
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
||||||
|
// Only on a fresh start: a recreated activity is handed the intent it
|
||||||
|
// was launched with again, and would offer to add the same share twice.
|
||||||
|
if (savedInstanceState == null) incomingShareLink.value = intent.shareLink()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
// Re-applied on every change, not just at startup: picking Latte from
|
// Re-applied on every change, not just at startup: picking Latte from
|
||||||
// the settings page has to turn the status bar's icons dark in the same
|
// the settings page has to turn the status bar's icons dark in the same
|
||||||
@@ -134,7 +140,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
ProtonStreamTheme {
|
ProtonStreamTheme {
|
||||||
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
ProtonStreamApp(
|
||||||
|
playerHost.value,
|
||||||
|
inPictureInPicture.value,
|
||||||
|
shareLink = incomingShareLink.value,
|
||||||
|
onShareLinkTaken = { incomingShareLink.value = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +170,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// singleTask: a link opened while the app is running arrives here, not in
|
||||||
|
// a second activity.
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.shareLink()?.let { incomingShareLink.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
override fun onUserLeaveHint() {
|
||||||
super.onUserLeaveHint()
|
super.onUserLeaveHint()
|
||||||
// The backstop for launchers that still send this instead of arming
|
// The backstop for launchers that still send this instead of arming
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.narl.protonstream.native
|
package io.narl.protonstream.native
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -56,5 +57,9 @@ object NativeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createEngine(): AndroidEngine =
|
private fun createEngine(): AndroidEngine =
|
||||||
AndroidEngine(paths(), KeystoreSecretStore(appContext))
|
AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply {
|
||||||
|
// The budget is Android's setting, so the engine starts on the
|
||||||
|
// default and is told the chosen one before anything streams.
|
||||||
|
setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,8 @@ data class MpvPlaybackState(
|
|||||||
val cachePercent: Double = 0.0,
|
val cachePercent: Double = 0.0,
|
||||||
/** A seek has been issued but playback has not resumed at the new position. */
|
/** A seek has been issued but playback has not resumed at the new position. */
|
||||||
val seeking: Boolean = false,
|
val seeking: Boolean = false,
|
||||||
|
/** How far the demuxer has read ahead, in seconds into the file. */
|
||||||
|
val cachedUntil: Double = 0.0,
|
||||||
) {
|
) {
|
||||||
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
/** Whether the picture is stopped for a reason the viewer did not choose. */
|
||||||
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
|
||||||
@@ -184,12 +186,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
audioLanguage: String?,
|
audioLanguage: String?,
|
||||||
subtitleLanguage: String?,
|
subtitleLanguage: String?,
|
||||||
subtitles: Boolean,
|
subtitles: Boolean,
|
||||||
|
hardwareDecoding: Boolean,
|
||||||
) {
|
) {
|
||||||
check(size > 0uL) { "Cannot play an empty stream" }
|
check(size > 0uL) { "Cannot play an empty stream" }
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val loaded = withOpenHandle {
|
val loaded = withOpenHandle {
|
||||||
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
|
nativeLoad(
|
||||||
|
it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles,
|
||||||
|
hardwareDecoding,
|
||||||
|
).also { loaded ->
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
// The outgoing stream is released by mpv closing it,
|
// The outgoing stream is released by mpv closing it,
|
||||||
// not from here: `loadfile` is asynchronous and its
|
// not from here: `loadfile` is asynchronous and its
|
||||||
@@ -309,6 +315,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
buffering = values[BUFFERING] != 0.0,
|
buffering = values[BUFFERING] != 0.0,
|
||||||
cachePercent = values[CACHE_PERCENT],
|
cachePercent = values[CACHE_PERCENT],
|
||||||
seeking = values[SEEKING] != 0.0,
|
seeking = values[SEEKING] != 0.0,
|
||||||
|
cachedUntil = values[CACHED_UNTIL],
|
||||||
)
|
)
|
||||||
mutableState.value = next
|
mutableState.value = next
|
||||||
// The listeners drive the media session and the player UI,
|
// The listeners drive the media session and the player UI,
|
||||||
@@ -398,7 +405,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private external fun nativeDestroy(handle: Long)
|
private external fun nativeDestroy(handle: Long)
|
||||||
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
private external fun nativeAttachSurface(handle: Long, surface: Surface)
|
||||||
private external fun nativeDetachSurface(handle: Long)
|
private external fun nativeDetachSurface(handle: Long)
|
||||||
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
|
private external fun nativeLoad(
|
||||||
|
handle: Long,
|
||||||
|
stream: Long,
|
||||||
|
start: Double,
|
||||||
|
audio: String?,
|
||||||
|
subtitle: String?,
|
||||||
|
subtitles: Boolean,
|
||||||
|
hardwareDecoding: Boolean,
|
||||||
|
): Boolean
|
||||||
private external fun nativePause(handle: Long, paused: Boolean)
|
private external fun nativePause(handle: Long, paused: Boolean)
|
||||||
private external fun nativeSeek(handle: Long, position: Double)
|
private external fun nativeSeek(handle: Long, position: Double)
|
||||||
private external fun nativeVolume(handle: Long, volume: Double)
|
private external fun nativeVolume(handle: Long, volume: Double)
|
||||||
@@ -419,7 +434,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
|
||||||
private const val FIELDS = 13
|
private const val FIELDS = 14
|
||||||
private const val GENERATION = 6
|
private const val GENERATION = 6
|
||||||
private const val WIDTH = 7
|
private const val WIDTH = 7
|
||||||
private const val HEIGHT = 8
|
private const val HEIGHT = 8
|
||||||
@@ -427,6 +442,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
|
|||||||
private const val BUFFERING = 10
|
private const val BUFFERING = 10
|
||||||
private const val CACHE_PERCENT = 11
|
private const val CACHE_PERCENT = 11
|
||||||
private const val SEEKING = 12
|
private const val SEEKING = 12
|
||||||
|
private const val CACHED_UNTIL = 13
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("pstr_android")
|
System.loadLibrary("pstr_android")
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.ui.theme.AccentButton
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
import io.narl.protonstream.ui.theme.AccentTrack
|
import io.narl.protonstream.ui.theme.AccentTrack
|
||||||
@@ -83,6 +84,19 @@ import io.narl.protonstream.ui.theme.EdgedButton
|
|||||||
import io.narl.protonstream.ui.theme.QuietButton
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
import io.narl.protonstream.ui.theme.TonalButton
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
import io.narl.protonstream.ui.thumbnailSource
|
import io.narl.protonstream.ui.thumbnailSource
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material.icons.filled.VideoLibrary
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import io.narl.protonstream.ui.RemoteArtwork
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -207,6 +221,7 @@ fun PlayerScreen(
|
|||||||
nativeHost.play(
|
nativeHost.play(
|
||||||
key, startup.nativeHandle, startup.size, startup.startPosition,
|
key, startup.nativeHandle, startup.size, startup.startPosition,
|
||||||
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
|
||||||
|
SettingsStore(context).hardwareDecoding,
|
||||||
)
|
)
|
||||||
// After the load, because mpv keeps none of the three across one.
|
// After the load, because mpv keeps none of the three across one.
|
||||||
nativeHost.setVolume(startup.volume)
|
nativeHost.setVolume(startup.volume)
|
||||||
@@ -272,6 +287,12 @@ fun PlayerScreen(
|
|||||||
nativeHost.beginTransition()
|
nativeHost.beginTransition()
|
||||||
onIndexChange(index - 1)
|
onIndexChange(index - 1)
|
||||||
}
|
}
|
||||||
|
fun jumpTo(target: Int) {
|
||||||
|
if (target == index || target !in episodes.indices) return
|
||||||
|
save()
|
||||||
|
nativeHost.beginTransition()
|
||||||
|
onIndexChange(target)
|
||||||
|
}
|
||||||
|
|
||||||
// What the media notification, the lock screen and Picture-in-Picture show.
|
// What the media notification, the lock screen and Picture-in-Picture show.
|
||||||
// None of them can reach this composition, and all three have to name the
|
// None of them can reach this composition, and all three have to name the
|
||||||
@@ -444,6 +465,9 @@ fun PlayerScreen(
|
|||||||
hasNext = next != null,
|
hasNext = next != null,
|
||||||
onPrevious = { retreat() },
|
onPrevious = { retreat() },
|
||||||
onNext = { advance() },
|
onNext = { advance() },
|
||||||
|
episodes = episodes,
|
||||||
|
playing = index,
|
||||||
|
onEpisode = { jumpTo(it) },
|
||||||
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
|
||||||
landscapeLocked = landscapeLocked,
|
landscapeLocked = landscapeLocked,
|
||||||
)
|
)
|
||||||
@@ -591,11 +615,15 @@ private fun PlayerControls(
|
|||||||
hasNext: Boolean,
|
hasNext: Boolean,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
playing: Int,
|
||||||
|
onEpisode: (Int) -> Unit,
|
||||||
onRotate: () -> Unit,
|
onRotate: () -> Unit,
|
||||||
landscapeLocked: Boolean,
|
landscapeLocked: Boolean,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var choosing by remember { mutableStateOf<String?>(null) }
|
var choosing by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showEpisodes by remember { mutableStateOf(false) }
|
||||||
var showChapters by remember { mutableStateOf(false) }
|
var showChapters by remember { mutableStateOf(false) }
|
||||||
var showSpeeds by remember { mutableStateOf(false) }
|
var showSpeeds by remember { mutableStateOf(false) }
|
||||||
// What the store says, so the dialog opens on the rate that is playing.
|
// What the store says, so the dialog opens on the rate that is playing.
|
||||||
@@ -638,7 +666,12 @@ private fun PlayerControls(
|
|||||||
scrubbing = null
|
scrubbing = null
|
||||||
},
|
},
|
||||||
valueRange = 0f..duration,
|
valueRange = 0f..duration,
|
||||||
track = { AccentTrack(it) },
|
track = {
|
||||||
|
AccentTrack(
|
||||||
|
it,
|
||||||
|
buffered = if (state.duration > 0) (state.cachedUntil / state.duration).toFloat() else 0f,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
|
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
|
||||||
}
|
}
|
||||||
@@ -664,6 +697,9 @@ private fun PlayerControls(
|
|||||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (episodes.size > 1) {
|
||||||
|
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||||
|
}
|
||||||
if (chapters.size > 1) {
|
if (chapters.size > 1) {
|
||||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||||
}
|
}
|
||||||
@@ -716,6 +752,14 @@ private fun PlayerControls(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showEpisodes) {
|
||||||
|
EpisodeChooser(
|
||||||
|
episodes = episodes,
|
||||||
|
playing = playing,
|
||||||
|
onDismiss = { showEpisodes = false },
|
||||||
|
onSelect = { showEpisodes = false; onEpisode(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showChapters) {
|
if (showChapters) {
|
||||||
ChapterChooser(
|
ChapterChooser(
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
@@ -870,6 +914,83 @@ private fun ChapterChooser(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title's episodes, from the player: a sheet rather than a dialog, opened
|
||||||
|
* scrolled to the one playing so the next few are in view without a search.
|
||||||
|
* Each row has its still, its name, and whether it is playing, watched or
|
||||||
|
* part way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeChooser(
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
playing: Int,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val list = rememberLazyListState(initialFirstVisibleItemIndex = (playing - 1).coerceAtLeast(0))
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Text(
|
||||||
|
"Episodes",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(state = list, contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
|
itemsIndexed(episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||||
|
val current = position == playing
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(position) }
|
||||||
|
.background(
|
||||||
|
if (current) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(Modifier.width(112.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl,
|
||||||
|
episode.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
episode.providerName ?: episode.label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
current -> "Playing"
|
||||||
|
episode.watched -> "${episode.label} · Watched"
|
||||||
|
episode.resumeAt != null -> "${episode.label} · Part way"
|
||||||
|
else -> episode.label
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (current) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The countdown into the next episode, shown once the credits run starts.
|
* The countdown into the next episode, shown once the credits run starts.
|
||||||
*
|
*
|
||||||
@@ -898,26 +1019,65 @@ private fun UpNextCard(
|
|||||||
}
|
}
|
||||||
// `showing` already implies a non-null episode, and the compiler knows it.
|
// `showing` already implies a non-null episode, and the compiler knows it.
|
||||||
if (!showing) return
|
if (!showing) return
|
||||||
|
// Eased between ticks, so the ring runs down rather than stepping once a
|
||||||
|
// second.
|
||||||
|
val ring by animateFloatAsState(
|
||||||
|
targetValue = remaining.toFloat() / UP_NEXT_SECONDS,
|
||||||
|
animationSpec = tween(durationMillis = 1_000, easing = LinearEasing),
|
||||||
|
label = "up-next countdown",
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.small)
|
.width(280.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
"Up next in ${remaining}s",
|
// The next episode's still, so the card says which episode
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
// without being read; the ring over it is both the countdown and
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// the button that skips it.
|
||||||
)
|
Box(
|
||||||
Text(
|
Modifier
|
||||||
upNext.label,
|
.width(112.dp)
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
.aspectRatio(16f / 9f)
|
||||||
maxLines = 1,
|
.clip(MaterialTheme.shapes.small)
|
||||||
overflow = TextOverflow.Ellipsis,
|
.clickable(onClick = onPlayNow),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
contentAlignment = Alignment.Center,
|
||||||
)
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
|
RemoteArtwork(
|
||||||
AccentButton(onClick = onPlayNow) { Text("Play now") }
|
upNext.stillUrl,
|
||||||
QuietButton(onClick = { held = true }) { Text("Watch till the end") }
|
upNext.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = upNext.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { ring },
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
color = Color.White,
|
||||||
|
trackColor = Color.White.copy(alpha = 0.25f),
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
)
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Play now", tint = Color.White)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
"Up next in ${remaining}s",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
upNext.providerName ?: upNext.label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text("Watch till the end")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface LibmpvHost {
|
|||||||
audioLanguage: String? = null,
|
audioLanguage: String? = null,
|
||||||
subtitleLanguage: String? = null,
|
subtitleLanguage: String? = null,
|
||||||
subtitles: Boolean = true,
|
subtitles: Boolean = true,
|
||||||
|
hardwareDecoding: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
/** Calls pstr_android_stream_release exactly once for a published handle. */
|
||||||
|
|||||||
@@ -20,9 +20,34 @@ class SettingsStore(context: Context) {
|
|||||||
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true)
|
||||||
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() }
|
||||||
|
|
||||||
private companion object {
|
/**
|
||||||
const val NAME = "proton_stream_settings"
|
* Whether mpv may decode in hardware. Off is the way out for a device whose
|
||||||
const val KEY_WIFI_ONLY = "wifi_only"
|
* MediaCodec decoder shows green or torn frames; it costs battery, so it is
|
||||||
const val KEY_BACKGROUND_AUDIO = "background_audio"
|
* on unless the viewer turns it off. Read at each load, so a change applies
|
||||||
|
* from the next file.
|
||||||
|
*/
|
||||||
|
var hardwareDecoding: Boolean
|
||||||
|
get() = preferences.getBoolean(KEY_HARDWARE_DECODING, true)
|
||||||
|
set(value) { preferences.edit().putBoolean(KEY_HARDWARE_DECODING, value).apply() }
|
||||||
|
|
||||||
|
/** The streaming cache's budget, in GiB. See [CACHE_BUDGET_CHOICES]. */
|
||||||
|
var cacheBudgetGib: Int
|
||||||
|
get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB)
|
||||||
|
set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The desktop's range, in the steps a phone's storage is bought in. */
|
||||||
|
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
|
||||||
|
|
||||||
|
/** `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing. */
|
||||||
|
const val DEFAULT_CACHE_BUDGET_GIB = 4
|
||||||
|
|
||||||
|
fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL
|
||||||
|
|
||||||
|
private const val NAME = "proton_stream_settings"
|
||||||
|
private const val KEY_WIFI_ONLY = "wifi_only"
|
||||||
|
private const val KEY_BACKGROUND_AUDIO = "background_audio"
|
||||||
|
private const val KEY_HARDWARE_DECODING = "hardware_decoding"
|
||||||
|
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ import uniffi.pstr_android.MetadataProvider
|
|||||||
import uniffi.pstr_android.MetadataSettingsRecord
|
import uniffi.pstr_android.MetadataSettingsRecord
|
||||||
import uniffi.pstr_android.StorageUsageRecord
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.WatchStateRecord
|
||||||
|
|
||||||
data class AppUiState(
|
data class AppUiState(
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
@@ -49,8 +50,16 @@ data class AppUiState(
|
|||||||
),
|
),
|
||||||
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
|
/** What Undo on [message] puts back, when the message offers one. */
|
||||||
|
val undo: List<WatchSnapshot>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One episode's watch state as it was before a change the viewer can undo.
|
||||||
|
* Null [state] means it had never been played.
|
||||||
|
*/
|
||||||
|
data class WatchSnapshot(val shareId: String, val linkId: String, val state: WatchStateRecord?)
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
class AppViewModel(context: Context, private val workManager: WorkManager) : ViewModel() {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
@@ -185,7 +194,101 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissMessage() = mutableState.update { it.copy(message = null) }
|
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||||
|
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||||
|
* menu should not cost them.
|
||||||
|
*/
|
||||||
|
fun setTitleWatched(title: TitleRecord, watched: Boolean) {
|
||||||
|
val episodes = title.seasons.flatMap { it.episodes }
|
||||||
|
changeWatch(
|
||||||
|
episodes,
|
||||||
|
"${title.canonicalName ?: title.name} 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.canonicalName ?: title.name} 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.canonicalName ?: title.name} removed from history",
|
||||||
|
) { engine, target, before ->
|
||||||
|
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put back what the last undoable change replaced. */
|
||||||
|
fun undo() {
|
||||||
|
val snapshots = mutableState.value.undo ?: return
|
||||||
|
mutableState.update { it.copy(message = null, undo = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
snapshots.forEach { snapshot ->
|
||||||
|
val state = snapshot.state
|
||||||
|
engine.saveWatchState(
|
||||||
|
snapshot.shareId,
|
||||||
|
snapshot.linkId,
|
||||||
|
state?.positionSecs ?: 0.0,
|
||||||
|
state?.durationSecs,
|
||||||
|
state?.watched ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeWatch(
|
||||||
|
episodes: List<EpisodeRecord>,
|
||||||
|
message: String,
|
||||||
|
change: (uniffi.pstr_android.AndroidEngine, EpisodeRecord, WatchStateRecord?) -> Unit,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val engine = NativeRuntime.engine()
|
||||||
|
episodes.map { episode ->
|
||||||
|
val before = engine.watchState(episode.shareId, episode.linkId)
|
||||||
|
change(engine, episode, before)
|
||||||
|
WatchSnapshot(episode.shareId, episode.linkId, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onSuccess { snapshots ->
|
||||||
|
mutableState.update { it.copy(message = message, undo = snapshots) }
|
||||||
|
}.onFailure(::reportError)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun reportError(error: Throwable) {
|
fun reportError(error: Throwable) {
|
||||||
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
mutableState.update { it.copy(message = error.message ?: "Unexpected error") }
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarColors
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab's page: its own name in an app bar, over whatever the page holds.
|
||||||
|
*
|
||||||
|
* One bar for the whole app said "proton-stream" on every page, which is a band
|
||||||
|
* of height spent naming the app the viewer is already in. The bar draws under
|
||||||
|
* the status bar itself, which is why the shell hands pages no top padding;
|
||||||
|
* [content] gets the rest of [padding] (the navigation bar and the mini
|
||||||
|
* transport) to apply as it scrolls.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun TabPage(
|
||||||
|
title: String,
|
||||||
|
padding: PaddingValues,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
// Pinned: the bar stays, and takes the container tint once the page has
|
||||||
|
// scrolled under it, so the edge between the two is still visible.
|
||||||
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
colors = pageBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f)) { content(padding) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's own background until something scrolls under the bar, then the
|
||||||
|
* container tint. A bar in `surface` from the start read as a band across the
|
||||||
|
* top of a page drawn in `background`.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared-element scopes the library-to-title switch runs in, or null where
|
||||||
|
* there is none — the two-pane layout, a screenshot, a preview. Locals rather
|
||||||
|
* than parameters, because the poster that animates is three calls deep in the
|
||||||
|
* grid and nothing between needs to know.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal val LocalSharedTransition = staticCompositionLocalOf<SharedTransitionScope?> { null }
|
||||||
|
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A title's art as one element across the switch: the poster tapped in the
|
||||||
|
* grid grows into the backdrop of the page it opens, and shrinks back on the
|
||||||
|
* way out, so the page is visibly *that* title's rather than a new screen.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.sharedArt(key: String): Modifier {
|
||||||
|
val transition = LocalSharedTransition.current ?: return this
|
||||||
|
val visibility = LocalPaneVisibility.current ?: return this
|
||||||
|
return with(transition) {
|
||||||
|
this@sharedArt.sharedBounds(rememberSharedContentState("art/$key"), visibility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EmptyState(title: String, body: String) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where Play on a title lands: the most recently played part-watched episode,
|
||||||
|
* else the first unwatched one, else the first — the desktop's `next_up`.
|
||||||
|
*/
|
||||||
|
internal fun nextUpIndex(playlist: List<EpisodeRecord>): Int =
|
||||||
|
playlist.withIndex().filter { it.value.resumeAt != null }.maxByOrNull { it.value.lastPlayed }?.index
|
||||||
|
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
|
||||||
|
internal fun formatBytes(bytes: ULong): String {
|
||||||
|
val value = bytes.toDouble()
|
||||||
|
return when {
|
||||||
|
value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024))
|
||||||
|
value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024))
|
||||||
|
value >= 1024 -> "%.1f KiB".format(value / 1024)
|
||||||
|
else -> "$bytes bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an episode row is headed with: "1. Mother and Children".
|
||||||
|
*
|
||||||
|
* The provider's name for the episode, else the name the filename gave it,
|
||||||
|
* else "Episode 1". The bare filename only when none of those exist — a column
|
||||||
|
* of `[Group] Show - S01E01.mkv` is forty rows that differ in two characters.
|
||||||
|
*/
|
||||||
|
internal fun EpisodeRecord.heading(position: Int): String {
|
||||||
|
val named = providerName ?: detail.takeIf { it != name }
|
||||||
|
val number = number?.toInt()
|
||||||
|
return when {
|
||||||
|
named != null && number != null -> "$number. $named"
|
||||||
|
named != null -> named
|
||||||
|
number != null -> "Episode $number"
|
||||||
|
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
|
||||||
|
/** One show's saved episodes, and the show itself where the library has it. */
|
||||||
|
private data class OfflineGroup(val name: String, val title: TitleRecord?, val files: List<OfflineRecord>)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DownloadsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onRemove: (OfflineRecord) -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG)
|
||||||
|
val downloads by work.observeAsState(emptyList())
|
||||||
|
// Reading retained metadata on every WorkInfo transition also hydrates
|
||||||
|
// paused/failed/cancelled entries after WorkManager history is pruned.
|
||||||
|
val retained = remember(downloads) { DownloadStateStore(context).records() }
|
||||||
|
// Which show each saved file is from. The offline record knows its episode
|
||||||
|
// but not its show, and the library is the only thing that does.
|
||||||
|
val groups = remember(state.offline, state.titles) {
|
||||||
|
val shows = state.titles.flatMap { title ->
|
||||||
|
title.seasons.flatMap(SeasonRecord::episodes).map { "${it.shareId}/${it.linkId}" to title }
|
||||||
|
}.toMap()
|
||||||
|
state.offline
|
||||||
|
.groupBy { shows["${it.shareId}/${it.linkId}"] }
|
||||||
|
.map { (title, files) ->
|
||||||
|
OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
// Deleting a saved episode asks first: it is the viewer's own choice to
|
||||||
|
// keep it, and getting it back is a download, not a tap.
|
||||||
|
var deleting by remember { mutableStateOf<OfflineRecord?>(null) }
|
||||||
|
TabPage("Downloads", padding) { body ->
|
||||||
|
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("No downloads", "Episodes, seasons and shows saved offline appear here.")
|
||||||
|
}
|
||||||
|
return@TabPage
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(body),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
if (state.offline.isNotEmpty()) {
|
||||||
|
item(key = "summary") {
|
||||||
|
Text(
|
||||||
|
"${state.offline.size} ${if (state.offline.size == 1) "episode" else "episodes"} · " +
|
||||||
|
"${formatBytes(state.offline.sumOf { it.size })} on this device",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What is still arriving comes first: it is the part of the page
|
||||||
|
// that changes, and the part with something to do.
|
||||||
|
if (retained.isNotEmpty()) {
|
||||||
|
item(key = "in-progress") { SectionHeading("In progress") }
|
||||||
|
items(retained, key = { "partial/${it.shareId}/${it.linkId}" }) { download ->
|
||||||
|
PartialRow(download, onPause, onResume, onDeletePartial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Under the show they belong to, as on desktop: a saved season is
|
||||||
|
// fourteen rows, and fourteen rows of "Episode 3" name nothing.
|
||||||
|
groups.forEach { group ->
|
||||||
|
item(key = "group/${group.name}") {
|
||||||
|
SectionHeading(
|
||||||
|
group.name,
|
||||||
|
"${group.files.size} ${if (group.files.size == 1) "episode" else "episodes"} · " +
|
||||||
|
formatBytes(group.files.sumOf { it.size }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(group.files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||||
|
SavedRow(file, group.title, onDelete = { deleting = file })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleting?.let { file ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { deleting = null },
|
||||||
|
title = { Text("Delete this download?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"${file.episode?.label ?: "The episode"} (${formatBytes(file.size)}) leaves this device. " +
|
||||||
|
"It still streams, and can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = {
|
||||||
|
deleting = null
|
||||||
|
onRemove(file)
|
||||||
|
}) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeading(title: String, caption: String? = null) {
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
caption?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A saved episode: its still, what it is, its size, and a way to remove it. */
|
||||||
|
@Composable
|
||||||
|
private fun SavedRow(file: OfflineRecord, title: TitleRecord?, onDelete: () -> Unit) {
|
||||||
|
val episode = file.episode
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode?.stillUrl ?: title?.backdropUrl,
|
||||||
|
title?.name ?: file.linkId,
|
||||||
|
Modifier.width(96.dp).aspectRatio(16f / 9f).clip(MaterialTheme.shapes.small),
|
||||||
|
fallback = episode?.thumbnailSource,
|
||||||
|
labelled = episode == null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(episode?.heading(0) ?: file.linkId, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(listOfNotNull(episode?.label, formatBytes(file.size)).joinToString(" · "))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Outlined.Delete, contentDescription = "Delete download")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A download not yet finished: how far it has got, why it stopped if it did,
|
||||||
|
* and pause or resume beside cancel.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PartialRow(
|
||||||
|
download: RetainedDownload,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onDeletePartial: (RetainedDownload) -> Unit,
|
||||||
|
) {
|
||||||
|
val active = download.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
download.status.replaceFirstChar { it.uppercase() },
|
||||||
|
download.total.takeIf { it > 0L }?.let {
|
||||||
|
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||||
|
},
|
||||||
|
).joinToString(" · "),
|
||||||
|
)
|
||||||
|
if (download.total > 0L) {
|
||||||
|
AccentProgress(
|
||||||
|
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (active) {
|
||||||
|
IconButton(onClick = { onPause(download) }) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { onResume(download) }) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Resume download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What has arrived so far is discarded; nothing finished is.
|
||||||
|
IconButton(onClick = { onDeletePartial(download) }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Cancel and delete what has arrived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
|
||||||
|
/** One played episode, with the title it belongs to and its place in that title's playlist. */
|
||||||
|
internal data class Played(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every episode that has been started or finished, newest first — the
|
||||||
|
* desktop's `Library::history`, over the records the library already holds.
|
||||||
|
*
|
||||||
|
* A row marked unwatched again sits at position zero, which is "never played"
|
||||||
|
* for every purpose here, so it is left out. Progress stands in for the
|
||||||
|
* position: it is non-zero exactly when the position is.
|
||||||
|
*/
|
||||||
|
internal fun history(titles: List<TitleRecord>): List<Played> =
|
||||||
|
titles.flatMap { title ->
|
||||||
|
title.seasons.flatMap(SeasonRecord::episodes).withIndex()
|
||||||
|
.filter { (_, episode) -> episode.watched || (episode.progress ?: 0.0) > 0.0 }
|
||||||
|
.map { (index, episode) -> Played(title, episode, index) }
|
||||||
|
}.sortedByDescending { it.episode.lastPlayed }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Today", "Yesterday", a weekday within the last week, then a date — how the
|
||||||
|
* history page heads each day.
|
||||||
|
*/
|
||||||
|
internal fun dayHeading(day: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when {
|
||||||
|
day == today -> "Today"
|
||||||
|
day == today.minusDays(1) -> "Yesterday"
|
||||||
|
day.isAfter(today.minusDays(7)) -> day.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
|
||||||
|
day.year == today.year -> day.format(DateTimeFormatter.ofPattern("d MMMM", locale))
|
||||||
|
else -> day.format(DateTimeFormatter.ofPattern("d MMMM yyyy", locale))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onPlay: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRemove: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
zone: ZoneId = remember { ZoneId.systemDefault() },
|
||||||
|
today: LocalDate = LocalDate.now(zone),
|
||||||
|
) {
|
||||||
|
val days = remember(state.titles) {
|
||||||
|
history(state.titles).groupBy { Instant.ofEpochSecond(it.episode.lastPlayed).atZone(zone).toLocalDate() }
|
||||||
|
}
|
||||||
|
if (days.isEmpty()) {
|
||||||
|
TabPage("History", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TabPage("History", padding) { body ->
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(body), contentPadding = PaddingValues(bottom = 24.dp)) {
|
||||||
|
days.forEach { (day, played) ->
|
||||||
|
item(key = "day/$day") {
|
||||||
|
Text(
|
||||||
|
dayHeading(day, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(played, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||||
|
HistoryRow(
|
||||||
|
entry,
|
||||||
|
onPlay = { onPlay(entry.title, entry.index) },
|
||||||
|
onTitle = { onTitle(entry.title) },
|
||||||
|
onRemove = { onRemove(entry.title, entry.episode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The still plays the episode, the text opens its title, and the cross takes
|
||||||
|
* it off the page — the same three targets the desktop row has.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, onRemove: () -> Unit) {
|
||||||
|
val episode = entry.episode
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(onClick = onPlay),
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
|
episode.label,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
entry.title.canonicalName ?: entry.title.name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
episode.providerName?.let { "${episode.label} · $it" } ?: episode.label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (episode.watched) "Finished" else episode.stoppedAt(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Remove from history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Stopped at 12:04 of 23:40", or as much of it as is known. */
|
||||||
|
private fun EpisodeRecord.stoppedAt(): String {
|
||||||
|
val at = resumeAt ?: return "Started"
|
||||||
|
val duration = progress?.takeIf { it > 0.0 }?.let { at / it }
|
||||||
|
return "Stopped at ${clock(at)}" + (duration?.let { " of ${clock(it)}" } ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clock(seconds: Double): String {
|
||||||
|
val total = seconds.toInt().coerceAtLeast(0)
|
||||||
|
val hours = total / 3_600
|
||||||
|
return if (hours > 0) "%d:%02d:%02d".format(hours, total % 3_600 / 60, total % 60)
|
||||||
|
else "%d:%02d".format(total / 60, total % 60)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The languages offered by name, as the three-letter tag a Matroska file
|
||||||
|
* carries.
|
||||||
|
*
|
||||||
|
* The same short list the desktop names tracks from (`pstr-player`'s
|
||||||
|
* `language_name`): what the shares this app is pointed at actually contain.
|
||||||
|
* mpv treats the two- and three-letter forms of a language as one, so the tag
|
||||||
|
* chosen here also matches a file that says "ja". Anything else is still one
|
||||||
|
* typed tag away.
|
||||||
|
*/
|
||||||
|
internal val LANGUAGES: List<Pair<String, String>> = listOf(
|
||||||
|
"jpn" to "Japanese",
|
||||||
|
"eng" to "English",
|
||||||
|
"ger" to "German",
|
||||||
|
"fre" to "French",
|
||||||
|
"spa" to "Spanish",
|
||||||
|
"ita" to "Italian",
|
||||||
|
"por" to "Portuguese",
|
||||||
|
"dut" to "Dutch",
|
||||||
|
"rus" to "Russian",
|
||||||
|
"pol" to "Polish",
|
||||||
|
"swe" to "Swedish",
|
||||||
|
"dan" to "Danish",
|
||||||
|
"nor" to "Norwegian",
|
||||||
|
"fin" to "Finnish",
|
||||||
|
"cze" to "Czech",
|
||||||
|
"hun" to "Hungarian",
|
||||||
|
"tur" to "Turkish",
|
||||||
|
"ara" to "Arabic",
|
||||||
|
"heb" to "Hebrew",
|
||||||
|
"hin" to "Hindi",
|
||||||
|
"kor" to "Korean",
|
||||||
|
"chi" to "Chinese",
|
||||||
|
"tha" to "Thai",
|
||||||
|
"vie" to "Vietnamese",
|
||||||
|
"ukr" to "Ukrainian",
|
||||||
|
"rum" to "Romanian",
|
||||||
|
"gre" to "Greek",
|
||||||
|
"ind" to "Indonesian",
|
||||||
|
"may" to "Malay",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Japanese", for a tag in any of the forms a file or the desktop may have
|
||||||
|
* stored — "jpn", "ja", "JA-jp" — and the tag itself for one not on the list.
|
||||||
|
* Null is no preference.
|
||||||
|
*/
|
||||||
|
internal fun languageLabel(tag: String?): String {
|
||||||
|
val value = tag?.trim()?.takeIf(String::isNotEmpty) ?: return "No preference"
|
||||||
|
return languageOf(value)?.second ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The listed language a tag names, matching either ISO 639 form. */
|
||||||
|
private fun languageOf(tag: String): Pair<String, String>? {
|
||||||
|
val base = tag.split('-', '_').first().lowercase()
|
||||||
|
return LANGUAGES.firstOrNull { (code, _) -> code == base || TWO_LETTER[code] == base || terminology(code) == base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 639-2/T, for the handful whose bibliographic code differs. */
|
||||||
|
private fun terminology(code: String): String? = when (code) {
|
||||||
|
"ger" -> "deu"
|
||||||
|
"fre" -> "fra"
|
||||||
|
"dut" -> "nld"
|
||||||
|
"cze" -> "ces"
|
||||||
|
"chi" -> "zho"
|
||||||
|
"rum" -> "ron"
|
||||||
|
"gre" -> "ell"
|
||||||
|
"may" -> "msa"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 639-1, the form a file or the desktop may also have stored. */
|
||||||
|
private val TWO_LETTER = mapOf(
|
||||||
|
"jpn" to "ja", "eng" to "en", "ger" to "de", "fre" to "fr", "spa" to "es", "ita" to "it",
|
||||||
|
"por" to "pt", "dut" to "nl", "rus" to "ru", "pol" to "pl", "swe" to "sv", "dan" to "da",
|
||||||
|
"nor" to "no", "fin" to "fi", "cze" to "cs", "hun" to "hu", "tur" to "tr", "ara" to "ar",
|
||||||
|
"heb" to "he", "hin" to "hi", "kor" to "ko", "chi" to "zh", "tha" to "th", "vie" to "vi",
|
||||||
|
"ukr" to "uk", "rum" to "ro", "gre" to "el", "ind" to "id", "may" to "ms",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A language preference as a row, opening a list to choose from.
|
||||||
|
*
|
||||||
|
* It was a free-text field asking for "jpn or eng", which is a question only
|
||||||
|
* someone who has read a Matroska header can answer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LanguageSetting(headline: String, value: String?, onChange: (String?) -> Unit) {
|
||||||
|
var choosing by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(languageLabel(value)) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(role = Role.Button) { choosing = true },
|
||||||
|
)
|
||||||
|
if (choosing) {
|
||||||
|
LanguageDialog(
|
||||||
|
headline,
|
||||||
|
value,
|
||||||
|
onDismiss = { choosing = false },
|
||||||
|
onChoose = {
|
||||||
|
choosing = false
|
||||||
|
onChange(it)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageDialog(headline: String, value: String?, onDismiss: () -> Unit, onChoose: (String?) -> Unit) {
|
||||||
|
val current = value?.let(::languageOf)?.first ?: value
|
||||||
|
// A tag that is not on the list starts in the field, so it is shown and
|
||||||
|
// can be edited rather than silently replaced.
|
||||||
|
var typed by remember { mutableStateOf(current?.takeIf { tag -> LANGUAGES.none { it.first == tag } }.orEmpty()) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(headline) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
|
||||||
|
item(key = "none") { LanguageOption("No preference", current == null) { onChoose(null) } }
|
||||||
|
items(LANGUAGES, key = { it.first }) { (code, name) ->
|
||||||
|
LanguageOption(name, current == code) { onChoose(code) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it },
|
||||||
|
label = { Text("Another tag") },
|
||||||
|
supportingText = { Text("As it appears in the file, such as \"tgl\"") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(enabled = typed.isNotBlank(), onClick = { onChoose(typed.trim().lowercase()) }) {
|
||||||
|
Text("Use tag")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageOption(name: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(name, style = MaterialTheme.typography.bodyLarge) },
|
||||||
|
leadingContent = { RadioButton(selected = selected, onClick = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable(role = Role.RadioButton, onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import java.time.LocalDate
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many shows the continue-watching shelf holds.
|
||||||
|
*
|
||||||
|
* A shelf is a shortcut, not a second library: past about this many it is
|
||||||
|
* quicker to search for the thing than to scroll the shelf looking for it.
|
||||||
|
*/
|
||||||
|
private const val CONTINUE_WATCHING_MAX = 12
|
||||||
|
|
||||||
|
/** One row of the continue-watching shelf: which episode, and where it sits. */
|
||||||
|
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun LibraryScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onSetTitleWatched: (TitleRecord, Boolean) -> Unit,
|
||||||
|
onForgetPosition: (TitleRecord, EpisodeRecord) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
onMatchChanged: () -> Unit = {},
|
||||||
|
onError: (Throwable) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// What a long press opened a menu for: a title, or a Continue watching card.
|
||||||
|
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
var menuResume by remember { mutableStateOf<Resumable?>(null) }
|
||||||
|
var matching by remember { mutableStateOf<TitleRecord?>(null) }
|
||||||
|
// Most recently played first, one episode per show: a shelf that lists four
|
||||||
|
// episodes of the same series is a shelf with room for nothing else.
|
||||||
|
val resumable = remember(state.titles) {
|
||||||
|
state.titles.mapNotNull { title ->
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
playlist.withIndex()
|
||||||
|
.filter { it.value.resumeAt != null && !it.value.watched }
|
||||||
|
.maxByOrNull { it.value.lastPlayed }
|
||||||
|
?.let { Resumable(title, it.value, it.index) }
|
||||||
|
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
|
}
|
||||||
|
// Search is an icon until it is wanted: a permanent field above the
|
||||||
|
// library spent a band of every visit on something used in few of them.
|
||||||
|
var searching by rememberSaveable { mutableStateOf(state.query.isNotEmpty()) }
|
||||||
|
val closeSearch = {
|
||||||
|
searching = false
|
||||||
|
onSearch("")
|
||||||
|
}
|
||||||
|
BackHandler(enabled = searching, onBack = closeSearch)
|
||||||
|
// The large title folds into the bar as the grid scrolls, and comes back
|
||||||
|
// only at the top: the page says what it is on arrival, then gets out of
|
||||||
|
// the way.
|
||||||
|
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
|
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
||||||
|
if (searching) {
|
||||||
|
SearchBar(state.query, onSearch, closeSearch)
|
||||||
|
} else {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text("Library") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { searching = true }) {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = "Search library")
|
||||||
|
}
|
||||||
|
// Pull to refresh is the gesture; the icon stays for a
|
||||||
|
// viewer who does not know it.
|
||||||
|
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
scrollBehavior = scroll,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = state.refreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.weight(1f).padding(padding),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> LibrarySkeleton()
|
||||||
|
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
|
||||||
|
"Nothing matches",
|
||||||
|
"No title in the library is called “${state.query.trim()}”.",
|
||||||
|
)
|
||||||
|
state.titles.isEmpty() -> EmptyState(
|
||||||
|
"Your library is empty",
|
||||||
|
"Add a Proton Drive public link under Shares, then refresh.",
|
||||||
|
)
|
||||||
|
else -> LibraryGrid(
|
||||||
|
state,
|
||||||
|
resumable,
|
||||||
|
onResume,
|
||||||
|
onTitle,
|
||||||
|
onTitleMenu = { menuFor = it },
|
||||||
|
onResumeMenu = { menuResume = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menuFor?.let { title ->
|
||||||
|
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
|
||||||
|
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount
|
||||||
|
TileMenu(title.canonicalName ?: title.name, title.caption(), onDismiss = { menuFor = null }) {
|
||||||
|
MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
|
||||||
|
onResume(title, nextUpIndex(playlist))
|
||||||
|
}
|
||||||
|
MenuRow(Icons.Default.Info, "Open") { onTitle(title) }
|
||||||
|
MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) }
|
||||||
|
MenuRow(
|
||||||
|
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
|
||||||
|
if (allWatched) "Mark unwatched" else "Mark watched",
|
||||||
|
) { onSetTitleWatched(title, !allWatched) }
|
||||||
|
// The same search the title page offers, for the tile that is
|
||||||
|
// plainly wrong — the wrong poster is what the grid shows.
|
||||||
|
MenuRow(Icons.Default.Edit, "Change match") { matching = title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matching?.let { title ->
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { matching = null },
|
||||||
|
onChanged = {
|
||||||
|
matching = null
|
||||||
|
onMatchChanged()
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
menuResume?.let { entry ->
|
||||||
|
TileMenu(
|
||||||
|
entry.title.canonicalName ?: entry.title.name,
|
||||||
|
entry.episode.label,
|
||||||
|
onDismiss = { menuResume = null },
|
||||||
|
) {
|
||||||
|
MenuRow(Icons.Default.PlayArrow, "Resume") { onResume(entry.title, entry.index) }
|
||||||
|
MenuRow(Icons.Default.Info, "Open") { onTitle(entry.title) }
|
||||||
|
MenuRow(Icons.Default.RemoveCircleOutline, "Remove from Continue watching") {
|
||||||
|
onForgetPosition(entry.title, entry.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bar while searching: back closes the search and clears it, and the field
|
||||||
|
* takes the keyboard as soon as it opens, since opening it is asking to type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(query: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
// A filled pill rather than an outlined form field: search is how
|
||||||
|
// the page is navigated, not a value being entered.
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onSearch,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(end = 8.dp).focusRequester(focus),
|
||||||
|
singleLine = true,
|
||||||
|
shape = CircleShape,
|
||||||
|
placeholder = { Text("Search library") },
|
||||||
|
trailingIcon = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearch("") }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = pageBarColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A long press's menu, from the bottom of the screen where the thumb already
|
||||||
|
* is. Each row closes it before doing its thing.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TileMenu(
|
||||||
|
heading: String,
|
||||||
|
caption: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
rows: @Composable MenuScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(Modifier.padding(bottom = 24.dp)) {
|
||||||
|
Text(
|
||||||
|
heading,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
caption,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
MenuScope(onDismiss).rows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MenuScope(val dismiss: () -> Unit)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MenuScope.MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label) },
|
||||||
|
leadingContent = { Icon(icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { dismiss(); onClick() }.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryGrid(
|
||||||
|
state: AppUiState,
|
||||||
|
resumable: List<Resumable>,
|
||||||
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onTitleMenu: (TitleRecord) -> Unit,
|
||||||
|
onResumeMenu: (Resumable) -> Unit,
|
||||||
|
) {
|
||||||
|
// Posters, three across on a phone. Cropped backdrops at full width fitted
|
||||||
|
// two titles to a screen, which is a library read one row at a time.
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(108.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
// Above the grid, and only when there is something in it: what a viewer
|
||||||
|
// opening the app wants is almost always the thing they were part way
|
||||||
|
// through. Inside the grid rather than pinned over it, so the shelf
|
||||||
|
// scrolls away with the rest.
|
||||||
|
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
|
||||||
|
featured?.let { title ->
|
||||||
|
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
FeaturedBanner(
|
||||||
|
title,
|
||||||
|
resume = resumable.firstOrNull { it.title.key == title.key },
|
||||||
|
onPlay = { onResume(title, it) },
|
||||||
|
onOpen = { onTitle(title) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.query.isBlank() && resumable.isNotEmpty()) {
|
||||||
|
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Column {
|
||||||
|
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(top = 10.dp),
|
||||||
|
) {
|
||||||
|
items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
|
||||||
|
ContinueCard(
|
||||||
|
entry,
|
||||||
|
onClick = { onResume(entry.title, entry.index) },
|
||||||
|
onLongClick = { onResumeMenu(entry) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.titles, key = { it.key }) { title ->
|
||||||
|
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's shape before the catalog has answered: posters and their two
|
||||||
|
* lines of text as blocks, so the page does not jump when the titles land.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LibrarySkeleton() {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(108.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||||
|
userScrollEnabled = false,
|
||||||
|
) {
|
||||||
|
items(SKELETON_TILES) {
|
||||||
|
Column {
|
||||||
|
Skeleton(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Skeleton(Modifier.fillMaxWidth(0.8f).height(12.dp).clip(MaterialTheme.shapes.small))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Skeleton(Modifier.fillMaxWidth(0.5f).height(10.dp).clip(MaterialTheme.shapes.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough placeholder tiles to fill a tablet's first screen. */
|
||||||
|
private const val SKELETON_TILES = 18
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title the banner shows: the one watched last, or with nothing part
|
||||||
|
* watched, one of the titles the provider has a backdrop for, turning over
|
||||||
|
* once a day — the desktop's `featured`.
|
||||||
|
*/
|
||||||
|
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
|
||||||
|
resumable.firstOrNull()?.let { return it.title }
|
||||||
|
val pictured = titles.filter { it.backdropUrl != null }
|
||||||
|
if (pictured.isEmpty()) return null
|
||||||
|
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Large art behind the name, rating, genres and three lines of synopsis. */
|
||||||
|
@Composable
|
||||||
|
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
|
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||||
|
).joinToString(" · ").ifEmpty { title.caption() },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
)
|
||||||
|
title.overview?.let {
|
||||||
|
Text(
|
||||||
|
it.trim(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist)) }) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(if (resume != null) "Resume" else "Play")
|
||||||
|
}
|
||||||
|
TonalButton(onClick = onOpen) { Text("More info") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A part-watched episode: its still, where it was left, and what is left of it. */
|
||||||
|
@Composable
|
||||||
|
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(220.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
entry.episode.stillUrl ?: entry.title.backdropUrl,
|
||||||
|
entry.title.canonicalName ?: entry.title.name,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = entry.episode.thumbnailSource,
|
||||||
|
)
|
||||||
|
entry.episode.progress?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
entry.title.canonicalName ?: entry.title.name,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One title: its poster, and its name and size under it rather than in a card. */
|
||||||
|
@Composable
|
||||||
|
private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.posterUrl ?: title.backdropUrl,
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
Modifier.sharedArt(title.key).fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// Two lines, not one: at a large font scale "2023 · 7 episodes" does
|
||||||
|
// not fit a poster's width, and one line cut it to "2023 · 7".
|
||||||
|
Text(
|
||||||
|
title.caption(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2023 · 11 episodes", or "1988 · Film" — never "1 episodes". */
|
||||||
|
private fun TitleRecord.caption(): String = listOfNotNull(
|
||||||
|
(metadataYear ?: year)?.toString(),
|
||||||
|
when {
|
||||||
|
(metadataKind ?: kind) == TitleType.FILM && episodeCount == 1uL -> "Film"
|
||||||
|
episodeCount == 1uL -> "1 episode"
|
||||||
|
else -> "$episodeCount episodes"
|
||||||
|
},
|
||||||
|
).joinToString(" · ")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "12 min left", from where the episode was left and how far through that is.
|
||||||
|
* Null when either is unknown — a guess would be worse than no figure.
|
||||||
|
*/
|
||||||
|
private fun EpisodeRecord.timeLeft(): String? {
|
||||||
|
val at = resumeAt ?: return null
|
||||||
|
val fraction = progress?.takeIf { it > 0.0 && it < 1.0 } ?: return null
|
||||||
|
val minutes = ((at / fraction - at) / 60).toInt()
|
||||||
|
return if (minutes < 1) "under a minute left" else "$minutes min left"
|
||||||
|
}
|
||||||
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
|
|||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import io.narl.protonstream.native.NativeRuntime
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -64,7 +76,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource
|
|||||||
*
|
*
|
||||||
* [fallback] is used only when there is no provider artwork or it fails to
|
* [fallback] is used only when there is no provider artwork or it fails to
|
||||||
* load, which — with metadata off — is the common case rather than the rare
|
* load, which — with metadata off — is the common case rather than the rare
|
||||||
* one.
|
* one. Until either answers, the box pulses as a skeleton; when neither has
|
||||||
|
* anything, it shows [ArtworkPlaceholder] rather than an empty card — without
|
||||||
|
* initials when [labelled] is false, for art with the name already over it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RemoteArtwork(
|
internal fun RemoteArtwork(
|
||||||
@@ -72,19 +86,98 @@ internal fun RemoteArtwork(
|
|||||||
description: String,
|
description: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
|
labelled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
val enabled = LocalArtworkLoading.current
|
||||||
LaunchedEffect(url, fallback) {
|
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
LaunchedEffect(url, fallback, enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
image = ArtworkLoad.Missing
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))
|
||||||
|
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
|
||||||
|
?: ArtworkLoad.Missing
|
||||||
}
|
}
|
||||||
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
|
when (val loaded = image) {
|
||||||
image?.let {
|
ArtworkLoad.Pending -> Skeleton(modifier)
|
||||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
||||||
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed interface ArtworkLoad {
|
||||||
|
data object Pending : ArtworkLoad
|
||||||
|
data object Missing : ArtworkLoad
|
||||||
|
data class Loaded(val image: ImageBitmap) : ArtworkLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [RemoteArtwork] fetches anything at all.
|
||||||
|
*
|
||||||
|
* Off in the screenshot tests: there is no bridge on the host, and whether a
|
||||||
|
* failed fetch lands before or after the capture would decide between a
|
||||||
|
* skeleton and a placeholder in the committed image.
|
||||||
|
*/
|
||||||
|
internal val LocalArtworkLoading = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something being loaded: the surface tint, breathing.
|
||||||
|
*
|
||||||
|
* A spinner per tile is a grid of spinners; a shape where the poster will be
|
||||||
|
* says what is coming and where, and settles the layout before it arrives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Skeleton(modifier: Modifier = Modifier) {
|
||||||
|
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||||
|
initialValue = 0.55f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||||
|
label = "skeleton alpha",
|
||||||
|
)
|
||||||
|
Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art for a title that has none: the accent as a gradient, and its initials.
|
||||||
|
*
|
||||||
|
* A black card with a play triangle on it said nothing about which title it
|
||||||
|
* was, so a grid of unmatched titles was a grid of identical cards. Each name
|
||||||
|
* sets where on the accent ramp its gradient starts, so neighbours differ
|
||||||
|
* while every one stays in the theme's colours.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val brush = remember(name, scheme.primary, scheme.secondary) {
|
||||||
|
val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f)
|
||||||
|
Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f)))
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) {
|
||||||
|
if (!labelled) return@BoxWithConstraints
|
||||||
|
val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() }
|
||||||
|
Text(
|
||||||
|
initials(name),
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
fontSize = size,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
||||||
|
* capitalised words, so particles and articles in a romanised name do not
|
||||||
|
* take a slot. A name with no capitals uses its first two words.
|
||||||
|
*/
|
||||||
|
internal fun initials(name: String): String {
|
||||||
|
val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true }
|
||||||
|
val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words }
|
||||||
|
return picked.take(2).joinToString("") { it.first().uppercase() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircle
|
||||||
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AppearanceState
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.MetadataProvider
|
||||||
|
import uniffi.pstr_android.FlavorChoice
|
||||||
|
import uniffi.pstr_android.AppearanceRecord
|
||||||
|
import uniffi.pstr_android.AccentChoice
|
||||||
|
import uniffi.pstr_android.PlaybackPrefsRecord
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
onMatchAgain: () -> Unit,
|
||||||
|
onClearCache: () -> Unit,
|
||||||
|
onRemoveAllOffline: () -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
initialPage: SettingsPage? = null,
|
||||||
|
// A parameter so the screenshot tests can pin it: read from the package,
|
||||||
|
// every version bump changed the committed image of this page.
|
||||||
|
version: String = appVersion(),
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settings = remember { SettingsStore(context) }
|
||||||
|
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
|
||||||
|
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
|
||||||
|
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
|
||||||
|
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
|
||||||
|
// Playback preferences are the shared store's, not this app's: they are the
|
||||||
|
// same file the desktop client reads, so a language chosen on one is the
|
||||||
|
// language the other starts in.
|
||||||
|
var prefs by remember { mutableStateOf<PlaybackPrefsRecord?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) {
|
||||||
|
val next = change(prefs ?: return)
|
||||||
|
prefs = next
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { NativeRuntime.engine().setPlaybackPrefs(next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
|
||||||
|
.onSuccess { prefs = it }
|
||||||
|
}
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
var showMetadata by remember { mutableStateOf(false) }
|
||||||
|
var legalDocument by remember { mutableStateOf<LegalDocument?>(null) }
|
||||||
|
// One row per area on the first page, each opening its own page: grouped
|
||||||
|
// on one page, the storage controls sat a long scroll below playback, and
|
||||||
|
// every setting added made the rest harder to find.
|
||||||
|
var page by rememberSaveable { mutableStateOf(initialPage) }
|
||||||
|
BackHandler(enabled = page != null) { page = null }
|
||||||
|
val back: @Composable () -> Unit = {
|
||||||
|
IconButton(onClick = { page = null }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (page) {
|
||||||
|
null -> TabPage("Settings", padding) { body ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
|
SettingsPage.entries.forEach { entry ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(entry.title) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
when (entry) {
|
||||||
|
SettingsPage.Playback -> "Autoplay, languages and decoding"
|
||||||
|
SettingsPage.Storage -> "${formatBytes(state.storage.cacheBytes)} cached · " +
|
||||||
|
"${state.storage.offlineCount} offline"
|
||||||
|
SettingsPage.Appearance -> "Palette, accent and gradient"
|
||||||
|
SettingsPage.Metadata -> if (state.metadataSettings.enabled) {
|
||||||
|
"On · ${state.metadataSettings.provider.displayName()}"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
}
|
||||||
|
SettingsPage.About -> "Version $version · licences"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { page = entry },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Playback -> SettingsSubPage(SettingsPage.Playback, padding, back) {
|
||||||
|
prefs?.let { current ->
|
||||||
|
SettingSwitch(
|
||||||
|
"Autoplay next episode",
|
||||||
|
"Starts the next episode when the credits begin",
|
||||||
|
current.autoplayNext,
|
||||||
|
) { on -> update { it.copy(autoplayNext = on) } }
|
||||||
|
// Off is the safer default: a mis-named chapter then costs a tap on
|
||||||
|
// Skip rather than a scene.
|
||||||
|
SettingSwitch(
|
||||||
|
"Skip openings and endings",
|
||||||
|
"Uses the release's chapters; off shows a Skip button instead",
|
||||||
|
current.autoSkip,
|
||||||
|
) { on -> update { it.copy(autoSkip = on) } }
|
||||||
|
SettingSwitch("Subtitles", "Shown by default where a file has them", current.subtitles) { on ->
|
||||||
|
update { it.copy(subtitles = on) }
|
||||||
|
}
|
||||||
|
// Preferences, not filters: a file with nothing in the language
|
||||||
|
// plays its own default track. A show that has been given its
|
||||||
|
// own choice keeps it.
|
||||||
|
LanguageSetting("Audio language", current.audioLanguage) { tag ->
|
||||||
|
update { it.copy(audioLanguage = tag) }
|
||||||
|
}
|
||||||
|
LanguageSetting("Subtitle language", current.subtitleLanguage) { tag ->
|
||||||
|
update { it.copy(subtitleLanguage = tag) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"Hardware decoding",
|
||||||
|
"Turn off if video shows green or torn frames",
|
||||||
|
hardwareDecoding,
|
||||||
|
) {
|
||||||
|
hardwareDecoding = it
|
||||||
|
settings.hardwareDecoding = it
|
||||||
|
}
|
||||||
|
SettingSwitch(
|
||||||
|
"Background audio",
|
||||||
|
"Keeps playing with the app closed, from the notification",
|
||||||
|
backgroundAudio,
|
||||||
|
) {
|
||||||
|
backgroundAudio = it
|
||||||
|
settings.backgroundAudio = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
|
||||||
|
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
|
||||||
|
wifiOnly = it
|
||||||
|
settings.wifiOnly = it
|
||||||
|
// Constraints are baked in at enqueue time, so a queue that already
|
||||||
|
// exists keeps the policy it was queued under until it is re-issued.
|
||||||
|
DownloadCoordinator.applyNetworkPolicy(context)
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||||
|
Text("Streaming cache")
|
||||||
|
Text(
|
||||||
|
"${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB used",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
AccentProgress(
|
||||||
|
progress = {
|
||||||
|
(state.storage.cacheBytes.toDouble() / SettingsStore.gibToBytes(cacheBudgetGib).toDouble())
|
||||||
|
.toFloat().coerceIn(0f, 1f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
// What has been watched is kept up to this much, so going back over
|
||||||
|
// a scene costs no download. A lower choice frees the difference at
|
||||||
|
// once. Wrapping, so five chips survive a narrow phone at a large
|
||||||
|
// font scale.
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SettingsStore.CACHE_BUDGET_CHOICES.forEach { gib ->
|
||||||
|
FilterChip(
|
||||||
|
selected = gib == cacheBudgetGib,
|
||||||
|
onClick = {
|
||||||
|
cacheBudgetGib = gib
|
||||||
|
settings.cacheBudgetGib = gib
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("$gib GiB") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The cache is rebuildable, so it goes without asking. Offline episodes
|
||||||
|
// are a choice the viewer made, so that one asks.
|
||||||
|
SettingAction("Clear streaming cache", "Frees ${formatBytes(state.storage.cacheBytes)}", onClick = onClearCache)
|
||||||
|
SettingAction(
|
||||||
|
"Delete all offline episodes",
|
||||||
|
"${state.storage.offlineCount} " + (if (state.storage.offlineCount == 1uL) "episode" else "episodes") +
|
||||||
|
" · ${formatBytes(state.storage.offlineBytes)}" +
|
||||||
|
(
|
||||||
|
state.storage.partialBytes.takeIf { it > 0uL }
|
||||||
|
?.let { " · ${formatBytes(it)} unfinished" }
|
||||||
|
.orEmpty()
|
||||||
|
),
|
||||||
|
enabled = state.storage.offlineCount > 0uL || state.storage.partialBytes > 0uL,
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) {
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
|
||||||
|
}
|
||||||
|
SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
|
||||||
|
SettingAction(
|
||||||
|
"Metadata enrichment",
|
||||||
|
if (state.metadataSettings.enabled) {
|
||||||
|
"On · ${state.metadataSettings.provider.displayName()} · sends title names to it"
|
||||||
|
} else {
|
||||||
|
"Off, the privacy default"
|
||||||
|
},
|
||||||
|
onClick = { showMetadata = true },
|
||||||
|
)
|
||||||
|
// Every title looked up again, matched ones included: the way out of a
|
||||||
|
// library the provider answered wrong, which otherwise stays wrong for
|
||||||
|
// as long as the match is remembered.
|
||||||
|
SettingAction(
|
||||||
|
"Match everything again",
|
||||||
|
"Looks every title up again, hand-picked matches excepted",
|
||||||
|
enabled = state.metadataSettings.enabled && !state.refreshing,
|
||||||
|
onClick = onMatchAgain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsPage.About -> SettingsSubPage(SettingsPage.About, padding, back) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Proton Stream") },
|
||||||
|
supportingContent = { Text("Version $version") },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
SettingAction("Licence", "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
||||||
|
legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt")
|
||||||
|
}
|
||||||
|
SettingAction("Third-party notices", "libmpv, Inter and the other components bundled") {
|
||||||
|
legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Delete all offline episodes?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " +
|
||||||
|
"will be removed from this device. Watch history is kept, and anything " +
|
||||||
|
"deleted can be downloaded again.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
legalDocument?.let { document ->
|
||||||
|
LegalDocumentDialog(document, onDismiss = { legalDocument = null })
|
||||||
|
}
|
||||||
|
if (showMetadata) {
|
||||||
|
MetadataSettingsDialog(
|
||||||
|
current = state.metadataSettings,
|
||||||
|
onDismiss = { showMetadata = false },
|
||||||
|
onSave = { enabled, provider, language, key ->
|
||||||
|
onSaveMetadata(enabled, provider, language, key)
|
||||||
|
showMetadata = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The areas the first page lists, each a page of its own. */
|
||||||
|
internal enum class SettingsPage(val title: String, val icon: ImageVector) {
|
||||||
|
Playback("Playback", Icons.Default.PlayCircle),
|
||||||
|
Storage("Downloads and storage", Icons.Default.Storage),
|
||||||
|
Appearance("Appearance", Icons.Default.Palette),
|
||||||
|
Metadata("Metadata", Icons.AutoMirrored.Filled.ManageSearch),
|
||||||
|
About("About", Icons.Default.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One area's page: its name, a way back, and its rows. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSubPage(
|
||||||
|
page: SettingsPage,
|
||||||
|
padding: PaddingValues,
|
||||||
|
back: @Composable () -> Unit,
|
||||||
|
rows: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
TabPage(page.title, padding, navigationIcon = back) { body ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(body).verticalScroll(rememberScrollState())) {
|
||||||
|
rows()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installed version, as the launcher and the store show it. */
|
||||||
|
@Composable
|
||||||
|
private fun appVersion(): String {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember {
|
||||||
|
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
|
||||||
|
.getOrNull() ?: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A switch with its one line of explanation; the whole row toggles it. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingSwitch(headline: String, supporting: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(supporting) },
|
||||||
|
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onChecked),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row that does something when tapped: opens a dialog, clears a cache. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingAction(
|
||||||
|
headline: String,
|
||||||
|
supporting: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = { Text(supporting) },
|
||||||
|
colors = if (enabled) {
|
||||||
|
colors
|
||||||
|
} else {
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||||
|
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(enabled = enabled, onClick = onClick),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetadataSettingsDialog(
|
||||||
|
current: uniffi.pstr_android.MetadataSettingsRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Boolean, MetadataProvider, String, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var enabled by remember { mutableStateOf(current.enabled) }
|
||||||
|
var provider by remember { mutableStateOf(current.provider) }
|
||||||
|
var language by remember { mutableStateOf(current.language) }
|
||||||
|
var apiKey by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Metadata enrichment") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.")
|
||||||
|
SettingToggle("Enable enrichment", enabled) { enabled = it }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MetadataProvider.entries.forEach { option ->
|
||||||
|
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required."
|
||||||
|
else "Film and television; requires a free TMDB API key.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
if (provider == MetadataProvider.TMDB) {
|
||||||
|
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
apiKey,
|
||||||
|
{ apiKey = it },
|
||||||
|
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(),
|
||||||
|
onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) },
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MetadataProvider.displayName(): String = when (this) {
|
||||||
|
MetadataProvider.ANI_LIST -> "AniList"
|
||||||
|
MetadataProvider.TMDB -> "TMDB"
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LegalDocument(val title: String, val assetPath: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var contents by remember(document.assetPath) { mutableStateOf("Loading…") }
|
||||||
|
LaunchedEffect(document.assetPath) {
|
||||||
|
contents = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
context.assets.open(document.assetPath).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
}.getOrElse { error -> "Unable to load this document: ${error.message}" }
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(document.title) },
|
||||||
|
text = {
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
|
||||||
|
item { Text(contents, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, Modifier.weight(1f))
|
||||||
|
Switch(checked = checked, onCheckedChange = onChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flavour, accent and gradients — the same three the desktop client offers.
|
||||||
|
*
|
||||||
|
* Every colour is resolved by Rust, so a swatch here is the colour the app will
|
||||||
|
* actually paint rather than an approximation of it, and the choice is stored in
|
||||||
|
* the file both clients read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppearancePicker() {
|
||||||
|
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) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 palette family is called. The desktop client says the same. */
|
||||||
|
private fun FlavorChoice.label() = when (this) {
|
||||||
|
FlavorChoice.PROTON -> "Proton"
|
||||||
|
FlavorChoice.LATTE -> "Catppuccin Latte"
|
||||||
|
FlavorChoice.FRAPPE -> "Catppuccin Frappé"
|
||||||
|
FlavorChoice.MACCHIATO -> "Catppuccin Macchiato"
|
||||||
|
FlavorChoice.MOCHA -> "Catppuccin Mocha"
|
||||||
|
FlavorChoice.PERSONA5 -> "Persona 5"
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.FolderShared
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SharesScreen(
|
||||||
|
shares: List<ShareRecord>,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
onRepair: (String, String, String?) -> Unit,
|
||||||
|
onRefresh: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
incomingLink: String?,
|
||||||
|
onIncomingLinkTaken: () -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||||
|
TabPage("Shares", padding) { body ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(body)) {
|
||||||
|
if (shares.isEmpty()) {
|
||||||
|
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||||
|
} else {
|
||||||
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||||
|
items(shares, key = { it.id }) { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
onRefresh = { onRefresh(share.id) },
|
||||||
|
onRepair = { repairing = share },
|
||||||
|
onRemove = { removing = share },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Where a thumb is, and the one thing this page is for. The share
|
||||||
|
// sheet and a tapped link land in the same form.
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showAdd = true },
|
||||||
|
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||||
|
text = { Text("Add share") },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAdd || incomingLink != null) {
|
||||||
|
AddShareDialog(
|
||||||
|
initialUrl = incomingLink.orEmpty(),
|
||||||
|
onDismiss = { showAdd = false; onIncomingLinkTaken() },
|
||||||
|
onAdd = onAdd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repairing?.let { share ->
|
||||||
|
RepairShareDialog(
|
||||||
|
share = share,
|
||||||
|
onDismiss = { repairing = null },
|
||||||
|
onRepair = { url, password -> onRepair(share.id, url, password) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
removing?.let { share ->
|
||||||
|
// Asked, because it cannot be undone: the link's secret, its offline
|
||||||
|
// files and its watch positions all go (`Catalog::remove_share`).
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { removing = null },
|
||||||
|
title = { Text("Remove ${share.name}?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Its titles leave the library, and its offline episodes and watch " +
|
||||||
|
"progress are deleted. Adding the link again starts it fresh.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One share: its name, how it is unlocked, and its actions behind a menu.
|
||||||
|
*
|
||||||
|
* Three differently styled controls beside the name squeezed it into a column
|
||||||
|
* a word wide; one icon leaves the row its width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ShareRow(
|
||||||
|
share: ShareRecord,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onRepair: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
var menu by remember { mutableStateOf(false) }
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link")
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.FolderShared,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { menu = true }) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = "Actions for ${share.name}")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||||
|
// One share, not the library: a link that has just had files
|
||||||
|
// added should not cost a walk of every other one.
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Refresh") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRefresh() },
|
||||||
|
)
|
||||||
|
// Not a remove: a share whose secret has become unreadable
|
||||||
|
// cannot be removed either, since removal deletes a secret
|
||||||
|
// the store can no longer touch.
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Re-enter link") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRepair() },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Remove") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||||
|
onClick = { menu = false; onRemove() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enter the link for a share the app can no longer decrypt its secret for.
|
||||||
|
*
|
||||||
|
* It has to be the *same* link — a different token is a different share, and
|
||||||
|
* repointing this one at it would leave every catalog row and every offline file
|
||||||
|
* describing something that is not there. Rust enforces that; this only says so.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RepairShareDialog(
|
||||||
|
share: ShareRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRepair: (String, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var url by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Re-enter link for ${share.name}") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"The stored credentials for this share cannot be read — usually after a " +
|
||||||
|
"screen-lock change or a device restore. Entering the same link again " +
|
||||||
|
"restores access without losing the library or anything downloaded.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onRepair(url.trim(), password); onDismiss() },
|
||||||
|
enabled = url.isNotBlank(),
|
||||||
|
) { Text("Restore") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddShareDialog(
|
||||||
|
initialUrl: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAdd: (String, String, String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
title = { Text("Add Proton Drive share") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true)
|
||||||
|
OutlinedTextField(
|
||||||
|
url,
|
||||||
|
{ url = it },
|
||||||
|
label = { Text("Public share URL") },
|
||||||
|
singleLine = true,
|
||||||
|
// The URL fragment is a secret. Password input disables
|
||||||
|
// IME learning/suggestions while normal long-press paste
|
||||||
|
// remains available.
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
password,
|
||||||
|
{ password = it },
|
||||||
|
label = { Text("Custom password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() },
|
||||||
|
enabled = name.isNotBlank() && url.isNotBlank(),
|
||||||
|
) { Text("Add") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.narl.protonstream.download.DownloadCoordinator
|
||||||
|
import io.narl.protonstream.download.DownloadStateStore
|
||||||
|
import io.narl.protonstream.download.RetainedDownload
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
|
import io.narl.protonstream.ui.theme.AccentButton
|
||||||
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.EdgedButton
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import io.narl.protonstream.ui.theme.solid
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.MatchRecord
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material.icons.filled.Replay
|
||||||
|
import androidx.compose.material.icons.filled.DownloadDone
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TitleScreen(
|
||||||
|
title: TitleRecord,
|
||||||
|
playerReady: Boolean,
|
||||||
|
onPlay: (List<EpisodeRecord>, Int) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPreferenceError: (Throwable) -> Unit,
|
||||||
|
onMetadataChanged: () -> Unit,
|
||||||
|
onSetWatched: (EpisodeRecord, Boolean) -> Unit,
|
||||||
|
padding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showMatch by remember(title.key) { mutableStateOf(false) }
|
||||||
|
var overviewOpen by remember(title.key) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Predictive back: the page shrinks towards the library as the gesture
|
||||||
|
// is dragged, and springs back if it is let go of, so the viewer sees
|
||||||
|
// where back goes before committing to it.
|
||||||
|
var backProgress by remember { mutableFloatStateOf(0f) }
|
||||||
|
PredictiveBackHandler { gesture ->
|
||||||
|
try {
|
||||||
|
gesture.collect { backProgress = it.progress }
|
||||||
|
onBack()
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
backProgress = 0f
|
||||||
|
throw cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display order across seasons: what previous/next and autoplay walk, and
|
||||||
|
// the same order the desktop client uses.
|
||||||
|
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
|
||||||
|
val nextUp = remember(playlist) { nextUpIndex(playlist) }
|
||||||
|
val upNext = playlist.getOrNull(nextUp)
|
||||||
|
// One season on screen at a time, opened on the one Play would land in: a
|
||||||
|
// forty-episode show as stacked sections is one long scroll to the part
|
||||||
|
// that matters.
|
||||||
|
var seasonIndex by remember(title.key) {
|
||||||
|
mutableStateOf(
|
||||||
|
title.seasons.indexOfFirst { season -> season.episodes.any { it.linkId == upNext?.linkId } }
|
||||||
|
.coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val season = title.seasons.getOrNull(seasonIndex)
|
||||||
|
// Live download state per episode, so a row can show progress and be paused
|
||||||
|
// without a trip to the Downloads tab.
|
||||||
|
val work by WorkManager.getInstance(context)
|
||||||
|
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||||
|
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||||
|
|
||||||
|
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||||
|
// button and synopsis lines two thousand pixels wide are read by turning
|
||||||
|
// the head. The backdrop still spans the window, but no taller than about
|
||||||
|
// half of it, or a landscape tablet opened on a picture and nothing else.
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
val scale = 1f - 0.1f * backProgress
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
shape = RoundedCornerShape((32 * backProgress).dp)
|
||||||
|
clip = backProgress > 0f
|
||||||
|
}
|
||||||
|
.padding(padding),
|
||||||
|
) {
|
||||||
|
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||||
|
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item(key = "backdrop") {
|
||||||
|
// Edge to edge, fading into the page: the art is the first thing
|
||||||
|
// the page says, and a framed thumbnail with a margin round it read
|
||||||
|
// as one card among the many below it.
|
||||||
|
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.45f to Color.Transparent,
|
||||||
|
1f to MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "heading") {
|
||||||
|
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||||
|
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
|
||||||
|
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
(title.metadataYear ?: title.year)?.toString(),
|
||||||
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
|
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||||
|
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
// The one thing the page is for, full width and naming what it
|
||||||
|
// will play: "Resume" alone left the viewer guessing which
|
||||||
|
// episode of forty it meant.
|
||||||
|
AccentButton(
|
||||||
|
onClick = { onPlay(playlist, nextUp) },
|
||||||
|
enabled = playerReady && playlist.isNotEmpty(),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
!playerReady -> "Player loading…"
|
||||||
|
upNext == null -> "Play"
|
||||||
|
upNext.resumeAt != null -> "Resume ${upNext.label}"
|
||||||
|
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
|
||||||
|
playlist.size == 1 -> "Play"
|
||||||
|
else -> "Play ${upNext.label}"
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Everything else as labelled icons in one row: four actions of
|
||||||
|
// three button styles used to wrap over three lines and bury
|
||||||
|
// the episodes under them.
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
// Only where it would do something different: an unstarted
|
||||||
|
// show already starts at the beginning.
|
||||||
|
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||||
|
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
|
||||||
|
onPlay(playlist, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||||
|
DownloadCoordinator.enqueue(context, playlist)
|
||||||
|
}
|
||||||
|
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||||
|
// The provider's own page for this title: where a viewer goes
|
||||||
|
// to check that the thing the app matched is what they have.
|
||||||
|
title.externalUrl?.let { url ->
|
||||||
|
TitleAction(
|
||||||
|
Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
title.metadataProvider?.displayName() ?: "Provider",
|
||||||
|
Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||||
|
}.onFailure(onPreferenceError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title.overview?.let { overview ->
|
||||||
|
// Three lines, then a tap for the rest: a synopsis with its
|
||||||
|
// source notes ran to a screen and a half on a phone.
|
||||||
|
Text(
|
||||||
|
overview.trim(),
|
||||||
|
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
.clickable { overviewOpen = !overviewOpen },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Said plainly, because it changes what a re-match will do: a
|
||||||
|
// hand-picked match is not overwritten by an automatic pass.
|
||||||
|
if (title.manualMatch) {
|
||||||
|
Text(
|
||||||
|
"Matched by hand",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "seasons") {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||||
|
FilterChip(
|
||||||
|
selected = index == seasonIndex,
|
||||||
|
onClick = { seasonIndex = index },
|
||||||
|
label = { Text(entry.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
season?.let {
|
||||||
|
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
season?.let { shown ->
|
||||||
|
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||||
|
Box(Modifier.padding(horizontal = side)) {
|
||||||
|
EpisodeRow(
|
||||||
|
episode = episode,
|
||||||
|
numbering = episode.numbering(shown.number, position),
|
||||||
|
position = position,
|
||||||
|
download = downloads.firstOrNull {
|
||||||
|
it.shareId == episode.shareId && it.linkId == episode.linkId
|
||||||
|
},
|
||||||
|
playerReady = playerReady,
|
||||||
|
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
||||||
|
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
||||||
|
onPause = { DownloadCoordinator.pause(context, it) },
|
||||||
|
onResume = { DownloadCoordinator.resume(context, it) },
|
||||||
|
onSetWatched = { onSetWatched(episode, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showMatch) {
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { showMatch = false },
|
||||||
|
onChanged = { showMatch = false; onMetadataChanged() },
|
||||||
|
onError = onPreferenceError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||||
|
private val READABLE_WIDTH = 720.dp
|
||||||
|
|
||||||
|
/** One of the title's secondary actions: an icon with its name under it. */
|
||||||
|
@Composable
|
||||||
|
private fun TitleAction(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `S03E38`, `E38`, or the row's place in the season.
|
||||||
|
*
|
||||||
|
* Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
|
||||||
|
* fallback to the season folder's number. The last case is deliberately *not*
|
||||||
|
* printed as an episode number — nothing in the name numbered this file, and
|
||||||
|
* `#4` says "fourth in the list" where `E04` would be a claim about the show.
|
||||||
|
* Some ordinal is required regardless: a season of rows that all read the same
|
||||||
|
* is a season the viewer cannot navigate at all.
|
||||||
|
*/
|
||||||
|
private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String {
|
||||||
|
val season = this.season ?: fallbackSeason
|
||||||
|
val number = this.number
|
||||||
|
return when {
|
||||||
|
season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt())
|
||||||
|
number != null -> "E%02d".format(number.toInt())
|
||||||
|
else -> "#${position + 1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One episode: play it, see where it was left, and drive its download.
|
||||||
|
*
|
||||||
|
* The row *is* the play button — the still, the number and the name are one
|
||||||
|
* target, which is what lets the trailing controls be two icons in a fixed
|
||||||
|
* column rather than a second row of buttons under every episode.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeRow(
|
||||||
|
episode: EpisodeRecord,
|
||||||
|
numbering: String,
|
||||||
|
position: Int,
|
||||||
|
download: RetainedDownload?,
|
||||||
|
playerReady: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onPause: (RetainedDownload) -> Unit,
|
||||||
|
onResume: (RetainedDownload) -> Unit,
|
||||||
|
onSetWatched: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val running = download?.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download?.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
val heading = episode.heading(position)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = playerReady, onClick = onPlay)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// With metadata off the provider has no still, and Proton's own
|
||||||
|
// thumbnail is a frame of this very episode. Where it was left sits
|
||||||
|
// on the still's lower edge, the way every streaming app shows it.
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(128.dp)
|
||||||
|
.aspectRatio(16f / 9f)
|
||||||
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
RemoteArtwork(
|
||||||
|
episode.stillUrl,
|
||||||
|
heading,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
|
)
|
||||||
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress.toFloat().coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
heading,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
// Seen episodes stay legible but stop competing with the
|
||||||
|
// one the viewer has not watched yet.
|
||||||
|
color = if (episode.watched) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
listOfNotNull(
|
||||||
|
numbering,
|
||||||
|
episode.size?.let(::formatBytes),
|
||||||
|
episode.airDate,
|
||||||
|
).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Watched is a judgement the viewer is allowed to overrule: a
|
||||||
|
// half-watched episode they are done with, or one the 90 % rule
|
||||||
|
// marked seen because they sat through the credits.
|
||||||
|
IconButton(onClick = { onSetWatched(!episode.watched) }) {
|
||||||
|
Icon(
|
||||||
|
if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle,
|
||||||
|
contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched",
|
||||||
|
tint = if (episode.watched) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// One slot, always the same width, whatever state the download
|
||||||
|
// is in — including "there is nothing to say", where an empty
|
||||||
|
// box keeps the rows above and below it aligned.
|
||||||
|
Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
when {
|
||||||
|
episode.offline -> Icon(
|
||||||
|
Icons.Default.DownloadDone,
|
||||||
|
contentDescription = "Saved offline",
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
running -> IconButton(onClick = { onPause(download!!) }) {
|
||||||
|
Icon(Icons.Default.Pause, contentDescription = "Pause download")
|
||||||
|
}
|
||||||
|
download != null -> IconButton(onClick = { onResume(download) }) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Resume download")
|
||||||
|
}
|
||||||
|
else -> IconButton(onClick = onDownload) {
|
||||||
|
Icon(Icons.Default.Download, contentDescription = "Download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episode.providerOverview?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
download?.takeIf { it.total > 0L && !episode.offline }?.let { active ->
|
||||||
|
AccentProgress(
|
||||||
|
progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
||||||
|
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ChangeMatchDialog(
|
||||||
|
title: TitleRecord,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
onError: (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Change match") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.")
|
||||||
|
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
|
||||||
|
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
|
||||||
|
searching = true
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) }
|
||||||
|
}.onSuccess { options = it }.onFailure(onError)
|
||||||
|
searching = false
|
||||||
|
}
|
||||||
|
}) { Text(if (searching) "Searching…" else "Search") }
|
||||||
|
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||||
|
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||||
|
EdgedButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||||
|
}.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(option.name, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
QuietButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
|
||||||
|
.onSuccess { onChanged() }.onFailure(onError)
|
||||||
|
}
|
||||||
|
}) { Text("Forget match") }
|
||||||
|
},
|
||||||
|
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -168,11 +168,21 @@ internal fun AccentProgress(
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
|
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier, buffered: Float = 0f) {
|
||||||
val brush = accentBrush()
|
val brush = accentBrush()
|
||||||
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
|
||||||
val radius = CornerRadius(size.height / 2)
|
val radius = CornerRadius(size.height / 2)
|
||||||
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
|
||||||
|
// What is already read ahead, lighter than the rest and under the
|
||||||
|
// played part: a jump that lands in it costs no fetch.
|
||||||
|
val ahead = size.width * buffered.coerceIn(0f, 1f)
|
||||||
|
if (ahead >= size.height) {
|
||||||
|
drawRoundRect(
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
size = Size(ahead, size.height),
|
||||||
|
cornerRadius = radius,
|
||||||
|
)
|
||||||
|
}
|
||||||
val done = size.width * state.coercedValueAsFraction
|
val done = size.width * state.coercedValueAsFraction
|
||||||
if (done >= size.height) {
|
if (done >= size.height) {
|
||||||
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ package io.narl.protonstream.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import io.narl.protonstream.R
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inter, the desktop client's face, from the same two files.
|
||||||
|
*
|
||||||
|
* Only Regular and SemiBold ship, as on the desktop. A `Medium` role resolves
|
||||||
|
* to Regular by the usual weight matching rather than to a synthesised bold,
|
||||||
|
* which is how the desktop draws it too.
|
||||||
|
*
|
||||||
|
* Above [ProtonTypography] on purpose: top-level properties initialise in
|
||||||
|
* file order, and a ramp built first would capture this as null and fall back
|
||||||
|
* to the system face without a word.
|
||||||
|
*/
|
||||||
|
internal val Inter = FontFamily(
|
||||||
|
Font(R.font.inter_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.inter_semibold, FontWeight.SemiBold),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type ramp, which is the desktop client's type ramp.
|
* The type ramp, which is the desktop client's type ramp.
|
||||||
*
|
*
|
||||||
@@ -72,4 +91,5 @@ private fun role(size: Float, weight: FontWeight) = TextStyle(
|
|||||||
fontSize = size.sp,
|
fontSize = size.sp,
|
||||||
lineHeight = (size * 1.35f).sp,
|
lineHeight = (size * 1.35f).sp,
|
||||||
fontWeight = weight,
|
fontWeight = weight,
|
||||||
|
fontFamily = Inter,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
The launcher tile: packaging/io.narl.proton-stream.svg's face gradient, on
|
||||||
|
the full 108dp canvas because the launcher cuts its own shape out of it.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:type="linear"
|
||||||
|
android:startX="0" android:startY="0"
|
||||||
|
android:endX="108" android:endY="108">
|
||||||
|
<item android:offset="0" android:color="#FFA48BFF" />
|
||||||
|
<item android:offset="0.55" android:color="#FF7552FF" />
|
||||||
|
<item android:offset="1" android:color="#FF5B36F2" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -1,6 +1,34 @@
|
|||||||
|
<!--
|
||||||
|
The play mark from packaging/io.narl.proton-stream.svg, cut into the blocks a
|
||||||
|
revision is fetched in. The paths are in the SVG's 128-unit space; the group
|
||||||
|
maps its 104-unit tile onto the 72dp a launcher shows of a 108dp layer.
|
||||||
|
It centres x=70, between the glyph's centroid (66) and its box (73): the
|
||||||
|
SVG's own centre, 64, leaves it sitting visibly right in a round mask.
|
||||||
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp" android:height="108dp"
|
android:width="108dp" android:height="108dp"
|
||||||
android:viewportWidth="108" android:viewportHeight="108">
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
<path android:fillColor="#CBA6F7" android:pathData="M24,22h60v64h-60z" />
|
<group
|
||||||
<path android:fillColor="#1E1E2E" android:pathData="M45,39l26,15 -26,15z" />
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-16" android:translateY="-8">
|
||||||
|
<group android:translateY="3">
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:fillColor="#4D2E1596" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:type="linear"
|
||||||
|
android:startX="0" android:startY="36"
|
||||||
|
android:endX="0" android:endY="88">
|
||||||
|
<item android:offset="0" android:color="#FFFFFFFF" />
|
||||||
|
<item android:offset="1" android:color="#FFECE6FF" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!-- The foreground's glyph as one flat shape, for themed launcher icons. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<group
|
||||||
|
android:pivotX="70" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-16" android:translateY="-8">
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<!--
|
<!--
|
||||||
The status-bar glyph. Android tints small icons to a single colour and drops
|
The status-bar glyph. Android tints small icons to a single colour and drops
|
||||||
everything that is not opaque, so this is the launcher mark reduced to one
|
everything that is not opaque, so this is the launcher's sliced play mark as
|
||||||
white silhouette rather than the two-tone drawable.
|
one white silhouette, scaled from the SVG's 128-unit space onto 24dp.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp" android:height="24dp"
|
android:width="24dp" android:height="24dp"
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
<path
|
<group
|
||||||
android:fillColor="#FFFFFFFF"
|
android:pivotX="70" android:pivotY="62"
|
||||||
android:fillType="evenOdd"
|
android:scaleX="0.37" android:scaleY="0.37"
|
||||||
android:pathData="M4,4h16v16h-16zM10,8.5l5.5,3.5 -5.5,3.5z" />
|
android:translateX="-58" android:translateY="-50">
|
||||||
|
<clip-path android:pathData="M0,0h63v128h-63zM68.5,0h10v128h-10zM84,0h44v128h-44z" />
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M50,41.5v41a6,6 0,0 0,9.1 5.1l34.2,-20.5a6,6 0,0 0,0 -10.3L59.1,36.4A6,6 0,0 0,50 41.5z" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">proton-stream</string>
|
<!-- Settings, recents, notifications and the share sheet: room for the full name. -->
|
||||||
|
<string name="app_name">Proton Stream</string>
|
||||||
|
<!--
|
||||||
|
Under the launcher icon, where about ten characters fit before the
|
||||||
|
launcher cuts the label off; "proton-stream" showed as "proton-st…".
|
||||||
|
-->
|
||||||
|
<string name="launcher_name">Stream</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.narl.protonstream
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IncomingShareLinkTest {
|
||||||
|
private val link = "https://drive.proton.me/urls/ABC123#s3cr3t"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a viewed share link is taken whole, fragment included`() {
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_VIEW, link, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a viewed url that is not an https share link is ignored`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "https://drive.proton.me/u/0/", null))
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_VIEW, "http://drive.proton.me/urls/ABC123#s3cr3t", null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared message yields the link inside it`() {
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "Season two is up: $link enjoy"))
|
||||||
|
assertEquals(link, shareLinkFrom(Intent.ACTION_SEND, null, "(<$link>)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared message without a link yields nothing`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_SEND, null, "no link here"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `other actions are ignored`() {
|
||||||
|
assertNull(shareLinkFrom(Intent.ACTION_MAIN, link, link))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ArtworkTest {
|
||||||
|
@Test
|
||||||
|
fun `initials skip the lower-case particles of a romanised name`() {
|
||||||
|
assertEquals("OK", initials("Oshi no Ko"))
|
||||||
|
assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-word name has one initial`() {
|
||||||
|
assertEquals("A", initials("Akira"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a name with no capitals falls back to its first two words`() {
|
||||||
|
assertEquals("BW", initials("blue world order"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `punctuation and separators are not words`() {
|
||||||
|
assertEquals("S2", initials("Steins;Gate - 2"))
|
||||||
|
assertEquals("", initials(" - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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())),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,283 @@
|
|||||||
|
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.EpisodeRecord
|
||||||
|
import uniffi.pstr_android.OfflineRecord
|
||||||
|
import uniffi.pstr_android.PaletteRecord
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.ShareRecord
|
||||||
|
import uniffi.pstr_android.StorageUsageRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every screen, drawn from fixed records, against a committed image.
|
||||||
|
*
|
||||||
|
* The screens are about to be redesigned one by one, and a layout regression —
|
||||||
|
* a row that wraps, a button pushed off the edge — compiles and passes every
|
||||||
|
* other test. Here it is a changed PNG in review. No artwork is loaded: the
|
||||||
|
* bridge is not available on the host, so every tile shows its generated
|
||||||
|
* placeholder, which is also what a title without metadata looks like on a
|
||||||
|
* phone.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
|
||||||
|
class ScreenshotTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
||||||
|
// The palette is process-wide and the sandbox outlives a test, so a
|
||||||
|
// light capture would otherwise repaint every one after it.
|
||||||
|
AppearanceState.apply(ProtonFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLight(name: String, content: @Composable () -> Unit) {
|
||||||
|
AppearanceState.apply(LATTE)
|
||||||
|
capture(name, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
|
compose.setContent {
|
||||||
|
ProtonStreamTheme {
|
||||||
|
CompositionLocalProvider(LocalArtworkLoading provides false) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library on a phone`() = capture("library-phone") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title in the light palette`() = captureLight("title-phone-light") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `a title on a tablet`() = capture("title-tablet") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
|
fun `the history page on a tablet`() = capture("history-tablet") {
|
||||||
|
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||||
|
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||||
|
// reads it from, so the scale is set there.
|
||||||
|
val density = LocalDensity.current
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty library`() = capture("library-empty") {
|
||||||
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||||
|
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the shares page`() = capture("shares-phone") {
|
||||||
|
SharesScreen(LIBRARY.shares, { _, _, _ -> }, { _, _, _ -> }, {}, {}, null, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the settings page`() = capture("settings-phone") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), version = "1.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the playback settings`() = capture("settings-playback") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Playback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the storage settings`() = capture("settings-storage") {
|
||||||
|
SettingsScreen(LIBRARY, { _, _, _, _ -> }, {}, {}, {}, PaddingValues(), SettingsPage.Storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the history page`() = capture("history-phone") {
|
||||||
|
// Fixed day and zone: "Today" and "Yesterday" are relative, and an
|
||||||
|
// image that changes with the date is no baseline.
|
||||||
|
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the downloads page with a season saved`() = capture("downloads-phone") {
|
||||||
|
val saved = SERIES.seasons.first().episodes.take(2).map {
|
||||||
|
OfflineRecord(it.shareId, it.linkId, "revision", 1_300_000_000uL, it)
|
||||||
|
}
|
||||||
|
// One title: the fixtures reuse link ids across titles, so with the
|
||||||
|
// whole library the episodes would be filed under whichever came last.
|
||||||
|
DownloadsScreen(LIBRARY.copy(titles = listOf(SERIES), offline = saved), {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the downloads page with nothing offline`() = capture("downloads-empty") {
|
||||||
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
|
||||||
|
val LATTE = PaletteRecord(
|
||||||
|
background = 0xFFEFF1F5u,
|
||||||
|
surface = 0xFFE6E9EFu,
|
||||||
|
sunken = 0xFFDCE0E8u,
|
||||||
|
card = 0xFFCCD0DAu,
|
||||||
|
cardHover = 0xFFBCC0CCu,
|
||||||
|
border = 0xFFBCC0CCu,
|
||||||
|
text = 0xFF4C4F69u,
|
||||||
|
muted = 0xFF6C6F85u,
|
||||||
|
accent = 0xFF732FCCu,
|
||||||
|
accentAlt = 0xFF1856D1u,
|
||||||
|
accentDim = 0xFFC4BBE4u,
|
||||||
|
onAccent = 0xFFEFF1F5u,
|
||||||
|
danger = 0xFFD20F39u,
|
||||||
|
elevated = 0xFFAAAEBDu,
|
||||||
|
dangerDim = 0xFFE7D1D5u,
|
||||||
|
light = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
||||||
|
// `detail` is the filename unless the filename named the episode,
|
||||||
|
// which is what the bridge sends.
|
||||||
|
val file = "[Group] Oshi no Ko - S%02dE%02d.mkv".format(season.toInt(), number.toInt())
|
||||||
|
return episodeRecord(season, number, file, watched, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun episodeRecord(season: UInt, number: UInt, file: String, watched: Boolean, progress: Double?) = EpisodeRecord(
|
||||||
|
shareId = "share",
|
||||||
|
volumeId = "volume",
|
||||||
|
linkId = "s${season}e$number",
|
||||||
|
name = file,
|
||||||
|
label = "S%02dE%02d".format(season.toInt(), number.toInt()),
|
||||||
|
detail = file,
|
||||||
|
season = season,
|
||||||
|
number = number,
|
||||||
|
size = 1_300_000_000uL,
|
||||||
|
progress = progress,
|
||||||
|
resumeAt = progress?.let { it * 1440 },
|
||||||
|
watched = watched,
|
||||||
|
offline = false,
|
||||||
|
providerName = if (number == 1u) "Mother and Children" else null,
|
||||||
|
providerOverview = null,
|
||||||
|
stillUrl = null,
|
||||||
|
airDate = null,
|
||||||
|
// 2026-09-21 for the part-watched one, the day before for the rest.
|
||||||
|
lastPlayed = when {
|
||||||
|
progress != null -> 1_790_000_000L
|
||||||
|
watched -> 1_789_900_000L - number.toLong() * 600
|
||||||
|
else -> 0L
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun title(key: String, name: String, seasons: List<SeasonRecord>, overview: String? = null) = TitleRecord(
|
||||||
|
key = key,
|
||||||
|
name = name,
|
||||||
|
year = 2023u,
|
||||||
|
kind = TitleType.SERIES,
|
||||||
|
watchedCount = seasons.sumOf { s -> s.episodes.count { it.watched } }.toULong(),
|
||||||
|
episodeCount = seasons.sumOf { it.episodes.size }.toULong(),
|
||||||
|
canonicalName = null,
|
||||||
|
originalName = null,
|
||||||
|
overview = overview,
|
||||||
|
metadataProvider = null,
|
||||||
|
metadataId = null,
|
||||||
|
metadataYear = null,
|
||||||
|
metadataKind = null,
|
||||||
|
posterUrl = null,
|
||||||
|
backdropUrl = null,
|
||||||
|
rating = 8.4,
|
||||||
|
genres = listOf("Drama", "Mystery"),
|
||||||
|
providerEpisodeCount = null,
|
||||||
|
externalUrl = null,
|
||||||
|
manualMatch = false,
|
||||||
|
seasons = seasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
|
||||||
|
.copy(kind = TitleType.FILM, year = 1988u)
|
||||||
|
|
||||||
|
val LIBRARY = AppUiState(
|
||||||
|
loading = false,
|
||||||
|
titles = listOf(
|
||||||
|
FILM,
|
||||||
|
SERIES,
|
||||||
|
title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))),
|
||||||
|
title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))),
|
||||||
|
),
|
||||||
|
shares = listOf(
|
||||||
|
ShareRecord("anime", "anime", true),
|
||||||
|
ShareRecord("films", "films", false),
|
||||||
|
),
|
||||||
|
storage = StorageUsageRecord(6_400_000_000uL, 3uL, 0uL, 1_100_000_000uL),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 628 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -14,6 +14,7 @@ androidx-test-core = "1.7.0"
|
|||||||
androidx-test-junit = "1.3.0"
|
androidx-test-junit = "1.3.0"
|
||||||
androidx-test-runner = "1.7.0"
|
androidx-test-runner = "1.7.0"
|
||||||
ktlint-plugin = "13.1.0"
|
ktlint-plugin = "13.1.0"
|
||||||
|
roborazzi = "1.76.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
androidx-core = { module = "androidx.core:core-ktx", version = "1.17.0" }
|
||||||
@@ -41,9 +42,13 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr
|
|||||||
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
|
||||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||||
|
roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
|
||||||
|
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
|
||||||
|
roborazzi-junit-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-plugin" }
|
||||||
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
@@ -717,7 +717,6 @@ struct Connection {
|
|||||||
generation: u64,
|
generation: u64,
|
||||||
library: Arc<SharedLibrary>,
|
library: Arc<SharedLibrary>,
|
||||||
open_failures: Vec<String>,
|
open_failures: Vec<String>,
|
||||||
#[allow(dead_code)]
|
|
||||||
source: StreamSource,
|
source: StreamSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +753,9 @@ pub struct AndroidEngine {
|
|||||||
/// between one episode and the next, so age — not just a share-store
|
/// between one episode and the next, so age — not just a share-store
|
||||||
/// mutation — is a reason to reauthenticate before opening anything.
|
/// mutation — is a reason to reauthenticate before opening anything.
|
||||||
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
session_refreshed: Mutex<HashMap<String, std::time::Instant>>,
|
||||||
|
/// The streaming cache's budget in bytes, for the next connection and,
|
||||||
|
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||||
|
cache_budget: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long a visitor session is assumed good for without a refresh.
|
/// How long a visitor session is assumed good for without a refresh.
|
||||||
@@ -798,6 +800,7 @@ impl AndroidEngine {
|
|||||||
share_generation: AtomicU64::new(0),
|
share_generation: AtomicU64::new(0),
|
||||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||||
session_refreshed: Mutex::new(HashMap::new()),
|
session_refreshed: Mutex::new(HashMap::new()),
|
||||||
|
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,6 +1282,22 @@ impl AndroidEngine {
|
|||||||
.map_err(BridgeError::from_display)?
|
.map_err(BridgeError::from_display)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set how much the streaming cache may hold. An open connection's cache
|
||||||
|
/// evicts down to it at once; a lower budget is a request for the space
|
||||||
|
/// back, not only a limit on what comes next.
|
||||||
|
pub fn set_stream_cache_budget(&self, bytes: u64) {
|
||||||
|
self.cache_budget.store(bytes, Ordering::Release);
|
||||||
|
let source = self
|
||||||
|
.connection
|
||||||
|
.lock()
|
||||||
|
.as_ref()
|
||||||
|
.map(|open| open.source.clone());
|
||||||
|
if let Some(source) = source {
|
||||||
|
self.runtime
|
||||||
|
.spawn(async move { source.set_disk_budget(bytes).await });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the cached blocks. Rebuildable by definition: this is the one thing
|
/// Drop the cached blocks. Rebuildable by definition: this is the one thing
|
||||||
/// here that can be reclaimed without losing something the viewer chose.
|
/// here that can be reclaimed without losing something the viewer chose.
|
||||||
pub fn clear_block_cache(&self) -> Result<u64, BridgeError> {
|
pub fn clear_block_cache(&self) -> Result<u64, BridgeError> {
|
||||||
@@ -1723,8 +1742,10 @@ impl AndroidEngine {
|
|||||||
let opener = Arc::new(LibraryOpener::new(Arc::clone(&library)));
|
let opener = Arc::new(LibraryOpener::new(Arc::clone(&library)));
|
||||||
let source = StreamSource::new(
|
let source = StreamSource::new(
|
||||||
opener,
|
opener,
|
||||||
StreamConfig::default()
|
StreamConfig::default().with_disk_cache(
|
||||||
.with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache())),
|
DiskCacheConfig::new(self.dirs.block_cache())
|
||||||
|
.with_budget(self.cache_budget.load(Ordering::Acquire)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(BridgeError::from_display)?;
|
.map_err(BridgeError::from_display)?;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 13 KiB |
@@ -46,7 +46,7 @@ Before submitting Android work, run the Rust workspace gate from the repository
|
|||||||
root, then:
|
root, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/build-android.sh check # ktlint, Android lint, host tests
|
bash scripts/build-android.sh check # ktlint, Android lint, host and screenshot tests
|
||||||
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
|
bash scripts/android-acceptance.sh --release # the on-device matrix, minified APK
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,11 +87,13 @@ GitHub Actions expects the keystore itself as the base64-encoded
|
|||||||
Keep the keystore and passwords in a durable external secret manager; losing the
|
Keep the keystore and passwords in a durable external secret manager; losing the
|
||||||
key prevents users from upgrading an installed APK.
|
key prevents users from upgrading an installed APK.
|
||||||
|
|
||||||
Tagged workflows currently retain signed builds as workflow artifacts only.
|
A `v*` tag runs `release.yml`, which calls `android.yml` with
|
||||||
Publishing them to a GitHub release or Play remains disabled until the pinned
|
`signed_release: true` and publishes the signed APK as
|
||||||
libmpv build and resulting APK have passed the on-device playback matrix. The
|
`proton-stream-<version>-android.apk` next to the desktop artifacts, together
|
||||||
main release workflow publishes desktop artifacts only and fails if an APK or
|
with the `mpv-android-source-<rev>.tar.gz` corresponding source for the bundled
|
||||||
AAB enters its artifact set.
|
libmpv. The AAB is kept as the `android-bundle` workflow artifact only; nothing
|
||||||
|
uploads it to Play. A manual `android.yml` run with `signed_release` builds the
|
||||||
|
same signed files without publishing them.
|
||||||
|
|
||||||
## Licensing and Native Playback
|
## Licensing and Native Playback
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ that needs a viewer's eyes is a scripted case rather than a paragraph in a
|
|||||||
checklist.
|
checklist.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/build-android.sh check # ktlint, Android lint, host tests
|
bash scripts/build-android.sh check # ktlint, Android lint, host and screenshot tests
|
||||||
bash scripts/android-acceptance.sh --list # what the device suite covers
|
bash scripts/android-acceptance.sh --list # what the device suite covers
|
||||||
bash scripts/android-acceptance.sh # debug APK, one attached device
|
bash scripts/android-acceptance.sh # debug APK, one attached device
|
||||||
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
|
bash scripts/android-acceptance.sh --release # the signed minified APK that ships
|
||||||
@@ -211,6 +211,19 @@ and checks the corner radius the desktop draws at. Both are lists of the *other*
|
|||||||
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
client's decisions, so a change to `pstr_app::theme::Role` that nobody carried
|
||||||
across fails here rather than being noticed in a screenshot.
|
across fails here rather than being noticed in a screenshot.
|
||||||
|
|
||||||
|
**Screenshot tests** (`ScreenshotTest`, Roborazzi) draw each screen from fixed
|
||||||
|
records — Library on a phone, a tablet, at font scale 1.5, while loading and
|
||||||
|
empty; a title on a phone and a tablet; History on a phone and a tablet; Shares;
|
||||||
|
the Settings index and its Playback and Storage pages; Downloads with a season
|
||||||
|
saved and empty; and the library and a title again in Catppuccin Latte — and
|
||||||
|
`check` compares them with the images in `app/src/test/screenshots`. A layout
|
||||||
|
regression compiles and passes everything else; here it fails, and the review
|
||||||
|
shows the changed image. After a deliberate change, `gradlew
|
||||||
|
recordRoborazziDebug` rewrites the images and they are committed with it.
|
||||||
|
Artwork loading is switched off (`LocalArtworkLoading`), so every tile shows
|
||||||
|
its generated placeholder whatever the timing, and the palette is the shipped
|
||||||
|
default except in the Latte captures.
|
||||||
|
|
||||||
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
**Instrumentation tests** (`app/src/androidTest`) are the things with no host
|
||||||
equivalent:
|
equivalent:
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
# builds from the registry alone.
|
# builds from the registry alone.
|
||||||
|
|
||||||
pkgname=proton-stream
|
pkgname=proton-stream
|
||||||
pkgver=0.5.0
|
pkgver=1.0.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="proton-stream">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="ps-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#8b6dff"/>
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
<stop offset="1" stop-color="#6d4aff"/>
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="ps-glyph" x1="0" y1="36" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<!-- The play mark, cut into the blocks a revision is fetched in: each one
|
||||||
|
decrypts on its own, which is the whole reason this app can seek. -->
|
||||||
|
<mask id="ps-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||||
|
<path d="M50 41.5v41a6 6 0 0 0 9.1 5.1l34.2-20.5a6 6 0 0 0 0-10.3L59.1 36.4A6 6 0 0 0 50 41.5z" fill="#FFFFFF"/>
|
||||||
|
<g fill="#000000">
|
||||||
|
<rect x="63" y="0" width="5.5" height="128"/>
|
||||||
|
<rect x="78.5" y="0" width="5.5" height="128"/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
|
|
||||||
<!-- The blocks a revision is fetched in: each one decrypts on its own, which
|
<!-- base: a rounded tile with a darker lower edge for depth -->
|
||||||
is the whole reason this app can seek. -->
|
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||||
<rect x="26" y="30" width="14" height="14" rx="3" fill="#ffffff" opacity="0.45"/>
|
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#ps-face)"/>
|
||||||
<rect x="26" y="50" width="14" height="14" rx="3" fill="#ffffff" opacity="0.7"/>
|
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||||
<rect x="26" y="70" width="14" height="14" rx="3" fill="#ffffff" opacity="0.45"/>
|
|
||||||
<rect x="26" y="90" width="14" height="14" rx="3" fill="#ffffff" opacity="0.25"/>
|
<!-- glyph shadow, then the glyph itself -->
|
||||||
<!-- play -->
|
<g transform="translate(0 3)">
|
||||||
<path fill="#ffffff" d="M56 34l46 30-46 30z"/>
|
<rect width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#ps-cut)"/>
|
||||||
|
</g>
|
||||||
|
<rect width="128" height="128" fill="url(#ps-glyph)" mask="url(#ps-cut)"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 923 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -8,13 +8,17 @@ android_dir="$repo_root/android"
|
|||||||
usage() {
|
usage() {
|
||||||
echo "usage: $0 {debug|check|release|signed|all}"
|
echo "usage: $0 {debug|check|release|signed|all}"
|
||||||
echo
|
echo
|
||||||
echo " check ktlint, Android lint and host unit tests — no device needed."
|
echo " check ktlint, Android lint, host unit tests and screenshot comparison —"
|
||||||
|
echo " no device needed. Re-record changed screens with"
|
||||||
|
echo " 'gradlew recordRoborazziDebug' and commit the images."
|
||||||
echo " On-device coverage is scripts/android-acceptance.sh."
|
echo " On-device coverage is scripts/android-acceptance.sh."
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
debug) tasks=(assembleDebug) ;;
|
debug) tasks=(assembleDebug) ;;
|
||||||
check) tasks=(ktlintCheck lintDebug testDebugUnitTest) ;;
|
# verifyRoborazziDebug *is* testDebugUnitTest, with the screenshots compared
|
||||||
|
# against src/test/screenshots instead of skipped.
|
||||||
|
check) tasks=(ktlintCheck lintDebug verifyRoborazziDebug) ;;
|
||||||
release) tasks=(assembleRelease bundleRelease) ;;
|
release) tasks=(assembleRelease bundleRelease) ;;
|
||||||
signed)
|
signed)
|
||||||
required=(
|
required=(
|
||||||
@@ -31,7 +35,7 @@ case "${1:-}" in
|
|||||||
done
|
done
|
||||||
tasks=(assembleRelease bundleRelease)
|
tasks=(assembleRelease bundleRelease)
|
||||||
;;
|
;;
|
||||||
all) tasks=(ktlintCheck lintDebug testDebugUnitTest assembleDebug assembleRelease bundleRelease) ;;
|
all) tasks=(ktlintCheck lintDebug verifyRoborazziDebug assembleDebug assembleRelease bundleRelease) ;;
|
||||||
*) usage >&2; exit 2 ;;
|
*) usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ source_dir="${PSTR_MPV_SOURCE_DIR:-$repo_root/.build/mpv-android}"
|
|||||||
revision=20a3fa526fac6d3fe267aee0d4c349893fee65a3
|
revision=20a3fa526fac6d3fe267aee0d4c349893fee65a3
|
||||||
destination="$repo_root/android/app/build/generated/mpv"
|
destination="$repo_root/android/app/build/generated/mpv"
|
||||||
|
|
||||||
|
# The staged tree depends only on the pinned revision and on this script, which
|
||||||
|
# also pins the NDK. A tree stamped with both is reused as is: that is what lets
|
||||||
|
# CI restore it from a cache instead of rebuilding FFmpeg and every other
|
||||||
|
# dependency from source on each run.
|
||||||
|
stamp="$revision $(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)"
|
||||||
|
if [[ "$(cat "$destination/STAMP" 2>/dev/null)" == "$stamp" &&
|
||||||
|
-f "$destination/jniLibs/arm64-v8a/libmpv.so" &&
|
||||||
|
-f "$destination/jniLibs/x86_64/libmpv.so" &&
|
||||||
|
-f "$destination/mpv-android-source-$revision.tar.gz" ]]; then
|
||||||
|
echo "libmpv $revision is already staged in $destination"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -f -- "$destination/STAMP"
|
||||||
|
|
||||||
if [[ ! -d "$source_dir/.git" ]]; then
|
if [[ ! -d "$source_dir/.git" ]]; then
|
||||||
git clone https://github.com/mpv-android/mpv-android.git "$source_dir"
|
git clone https://github.com/mpv-android/mpv-android.git "$source_dir"
|
||||||
fi
|
fi
|
||||||
@@ -191,3 +205,5 @@ tar \
|
|||||||
-czf "$destination/mpv-android-source-$revision.tar.gz" \
|
-czf "$destination/mpv-android-source-$revision.tar.gz" \
|
||||||
.
|
.
|
||||||
printf '%s\n' "$revision" > "$destination/REVISION"
|
printf '%s\n' "$revision" > "$destination/REVISION"
|
||||||
|
# Last, so an interrupted run never leaves a stamp over a partial tree.
|
||||||
|
printf '%s\n' "$stamp" > "$destination/STAMP"
|
||||||