Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0563c91b9e | ||
|
|
3b9f4b549c | ||
|
|
d5c819ee94 | ||
|
|
153859a735 | ||
|
|
1c61aadb22 | ||
|
|
8d40b211ed | ||
|
|
6229a59561 | ||
|
|
cfd654b267 | ||
|
|
0bc7cba428 | ||
|
|
65ecc099b6 | ||
|
|
65a6ae9373 | ||
|
|
2fbbd0e090 | ||
|
|
eee45bc8f8 | ||
|
|
1c8df80c27 | ||
|
|
5673a44310 | ||
|
|
c63f08ac91 | ||
|
|
f2e69dc852 | ||
|
|
8a1807c262 | ||
|
|
47b33cdb91 | ||
|
|
2d7d895dda | ||
|
|
0d33799199 | ||
|
|
2ac906cf37 | ||
|
|
cf065fc5fe | ||
|
|
55ee412a1b | ||
|
|
2275df40a8 | ||
|
|
78ffafe21a | ||
|
|
6e09129d0c | ||
|
|
476fec8c04 | ||
|
|
7af0969e38 | ||
|
|
3df0644bd8 | ||
|
|
5d079b05c9 | ||
|
|
4d44db0949 | ||
|
|
4a54af3245 | ||
|
|
7e734dbf19 | ||
|
|
e7eeaa518f | ||
|
|
39866bee8d | ||
|
|
85092fa893 | ||
|
|
2128a2d705 | ||
|
|
558446b9e4 | ||
|
|
6518aef5ee | ||
|
|
9037e6c2d5 | ||
|
|
d3325843a9 | ||
|
|
79c3f6885d | ||
|
|
a432a93cf9 | ||
|
|
935a985589 | ||
|
|
7d03736954 | ||
|
|
53a1d0afda | ||
|
|
959547cdc8 | ||
|
|
c60ffc7ab1 | ||
|
|
c5808a5e50 | ||
|
|
41d1fbf073 | ||
|
|
ff362d0d08 | ||
|
|
4027ab5fb3 | ||
|
|
c5adcddb1e | ||
|
|
8f78f5c1b1 | ||
|
|
6f28a9c377 | ||
|
|
d3c5f071fb |
No files matched your search
@@ -3,7 +3,15 @@ name: Android
|
|||||||
on:
|
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: >-
|
||||||
@@ -95,7 +112,33 @@ jobs:
|
|||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
# One cache for both jobs: the signed build compiles the same crates for
|
||||||
|
# the same targets. Saved on failure too, so a run that breaks late does
|
||||||
|
# not throw away everything it compiled before the break.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
with:
|
||||||
|
shared-key: android
|
||||||
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# 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:
|
||||||
@@ -126,7 +169,7 @@ jobs:
|
|||||||
|
|
||||||
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 +184,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: >-
|
||||||
@@ -162,7 +214,33 @@ jobs:
|
|||||||
toolchain: '1.96'
|
toolchain: '1.96'
|
||||||
targets: aarch64-linux-android,x86_64-linux-android
|
targets: aarch64-linux-android,x86_64-linux-android
|
||||||
|
|
||||||
|
# One cache for both jobs: the signed build compiles the same crates for
|
||||||
|
# the same targets. Saved on failure too, so a run that breaks late does
|
||||||
|
# not throw away everything it compiled before the break.
|
||||||
- uses: Swatinem/rust-cache@v2.9.1
|
- uses: Swatinem/rust-cache@v2.9.1
|
||||||
|
with:
|
||||||
|
shared-key: android
|
||||||
|
cache-on-failure: true
|
||||||
|
|
||||||
|
# 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 +266,24 @@ 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
|
||||||
|
|
||||||
|
# The APK bundles a GPL libmpv, so its corresponding source ships next to
|
||||||
|
# it. The AAB is for Play only and stays out of the GitHub release.
|
||||||
|
- name: Stage release files
|
||||||
|
run: |
|
||||||
|
version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||||
|
mkdir -p dist/android
|
||||||
|
cp android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
"dist/android/proton-stream-$version-android.apk"
|
||||||
|
cp android/app/build/generated/mpv/mpv-android-source-*.tar.gz dist/android/
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-signed
|
name: android
|
||||||
path: |
|
path: dist/android/*
|
||||||
android/app/build/outputs/apk/release/*.apk
|
if-no-files-found: error
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-bundle
|
||||||
|
path: android/app/build/outputs/bundle/release/*.aab
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -20,9 +20,6 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Android remains a workflow artifact only until the native libmpv adapter,
|
|
||||||
# corresponding-source bundle, and on-device verification are complete.
|
|
||||||
ANDROID_PUBLIC_RELEASE_ENABLED: 'false'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
@@ -60,11 +57,6 @@ jobs:
|
|||||||
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
! grep -q '^\[patch\.crates-io\]' Cargo.toml ||
|
||||||
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
{ echo "::error::Cargo.toml carries [patch.crates-io]; releases build against the registry"; exit 1; }
|
||||||
|
|
||||||
- name: Android publication remains blocked
|
|
||||||
run: |
|
|
||||||
test "$ANDROID_PUBLIC_RELEASE_ENABLED" = false
|
|
||||||
echo "Android APK/AAB publication is blocked until native playback is release-ready."
|
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
name: Linux artifacts
|
name: Linux artifacts
|
||||||
needs: verify
|
needs: verify
|
||||||
@@ -148,25 +140,38 @@ jobs:
|
|||||||
dist/*.msi
|
dist/*.msi
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The signed APK plus the corresponding source of the libmpv it bundles. The
|
||||||
|
# keystore and its passwords come from the ANDROID_RELEASE_* secrets.
|
||||||
|
android:
|
||||||
|
name: Android
|
||||||
|
needs: verify
|
||||||
|
uses: ./.github/workflows/android.yml
|
||||||
|
with:
|
||||||
|
signed_release: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish desktop artifacts
|
name: Publish
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
needs: [linux, windows]
|
needs: [linux, windows, android]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
# By name: the Android workflow also uploads the AAB, which is for Play
|
||||||
|
# and does not belong on the release.
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
|
name: linux
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: windows
|
||||||
|
path: artifacts
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: android
|
||||||
path: artifacts
|
path: artifacts
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Refuse mobile artifacts while Android publishing is blocked
|
|
||||||
run: |
|
|
||||||
if find artifacts -type f \( -name '*.apk' -o -name '*.aab' \) -print -quit | grep -q .; then
|
|
||||||
echo "::error::Android publishing is disabled while native playback remains incomplete"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checksums
|
- name: Checksums
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -20,3 +20,9 @@
|
|||||||
/packaging/pkg/
|
/packaging/pkg/
|
||||||
/packaging/src/
|
/packaging/src/
|
||||||
/packaging/*.pkg.tar.zst
|
/packaging/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# iOS generated state: XcodeGen owns the project and the Info.plist, and
|
||||||
|
# scripts/build-ios.sh writes the bindings and DerivedData under ios/build.
|
||||||
|
/ios/build/
|
||||||
|
/ios/*.xcodeproj/
|
||||||
|
/ios/ProtonStream/Info.plist
|
||||||
@@ -119,12 +119,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android-activity"
|
name = "android-activity"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -609,6 +603,12 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1029,6 +1029,36 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"block",
|
||||||
|
"cocoa-foundation",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics 0.22.3",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa-foundation"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"block",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics-types",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -1136,6 +1166,19 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-graphics"
|
||||||
|
version = "0.22.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-graphics-types",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-graphics"
|
name = "core-graphics"
|
||||||
version = "0.23.2"
|
version = "0.23.2"
|
||||||
@@ -1145,7 +1188,7 @@ dependencies = [
|
|||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1377,6 +1420,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dbus-crossroads"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8"
|
||||||
|
dependencies = [
|
||||||
|
"dbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus-secret-service"
|
name = "dbus-secret-service"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -1709,6 +1761,15 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "egui-phosphor"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4"
|
||||||
|
dependencies = [
|
||||||
|
"egui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "egui-wgpu"
|
name = "egui-wgpu"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@@ -2003,12 +2064,6 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foldhash"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2024,6 +2079,15 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2031,7 +2095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2045,6 +2109,12 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2279,7 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"log",
|
"log",
|
||||||
"peniko",
|
"peniko",
|
||||||
@@ -2403,6 +2473,25 @@ dependencies = [
|
|||||||
"euclid",
|
"euclid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -2436,24 +2525,13 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.15.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash 0.1.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2462,7 +2540,7 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash 0.2.0",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2592,6 +2670,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -3150,11 +3229,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.18.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3163,6 +3242,15 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3483,6 +3571,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||||
|
dependencies = [
|
||||||
|
"malloc_buf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -4202,12 +4299,12 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.5.1"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d"
|
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -4229,13 +4326,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.5.1"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3"
|
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.23.0",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4262,7 +4359,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-android"
|
name = "pstr-android"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
@@ -4281,11 +4378,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-app"
|
name = "pstr-app"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"dbus",
|
||||||
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
|
"egui-phosphor",
|
||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4293,17 +4394,22 @@ dependencies = [
|
|||||||
"pstr-meta",
|
"pstr-meta",
|
||||||
"pstr-player",
|
"pstr-player",
|
||||||
"pstr-stream",
|
"pstr-stream",
|
||||||
|
"raw-window-handle",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"skrifa",
|
||||||
|
"souvlaki",
|
||||||
|
"tauri-winrt-notification",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-cli"
|
name = "pstr-cli"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -4321,7 +4427,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-core"
|
name = "pstr-core"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4339,7 +4445,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-meta"
|
name = "pstr-meta"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pstr-core",
|
"pstr-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4352,7 +4458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-player"
|
name = "pstr-player"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -4367,7 +4473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pstr-stream"
|
name = "pstr-stream"
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4647,6 +4753,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -5401,6 +5508,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "souvlaki"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"block",
|
||||||
|
"cocoa",
|
||||||
|
"core-graphics 0.22.3",
|
||||||
|
"dbus",
|
||||||
|
"dbus-crossroads",
|
||||||
|
"dispatch",
|
||||||
|
"objc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"windows 0.44.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
@@ -5489,6 +5614,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"windows 0.62.2",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -5644,6 +5780,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -6539,6 +6676,36 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.44.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -6552,6 +6719,17 @@ dependencies = [
|
|||||||
"windows-strings",
|
"windows-strings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -6580,6 +6758,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6715,6 +6903,24 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-version"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6911,7 +7117,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation 0.9.4",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics",
|
"core-graphics 0.23.2",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.1"
|
version = "0.5.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/narrrl/proton-stream"
|
repository = "https://github.com/narrrl/proton-stream"
|
||||||
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
|||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.5.1" }
|
proton-sdk = { version = "0.7.2" }
|
||||||
proton-drive-rs = { version = "0.5.1" }
|
proton-drive-rs = { version = "0.7.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pstr-core = { path = "crates/pstr-core" }
|
pstr-core = { path = "crates/pstr-core" }
|
||||||
@@ -51,6 +51,8 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
# Local dates for the history page. Already in the tree through the SDK.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
# `bundled` so a Windows build needs no system SQLite.
|
# `bundled` so a Windows build needs no system SQLite.
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
@@ -77,6 +79,12 @@ eframe = { version = "0.35", default-features = false, features = [
|
|||||||
"x11",
|
"x11",
|
||||||
] }
|
] }
|
||||||
egui = "0.35"
|
egui = "0.35"
|
||||||
|
# The icons on buttons. Glyphs like ⏭ and 🔊 come out of whichever fallback
|
||||||
|
# font has them, at its own size and baseline; an icon font is one set.
|
||||||
|
egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] }
|
||||||
|
# Tests only: reads the bundled fonts' character maps. The version epaint
|
||||||
|
# already builds, so it costs no extra compile.
|
||||||
|
skrifa = { version = "0.42", default-features = false, features = ["std"] }
|
||||||
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also
|
||||||
# pulls in every exotic format is weight this app has no use for.
|
# pulls in every exotic format is weight this app has no use for.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
@@ -84,9 +92,20 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
|
|||||||
# behind a poisoned lock for the rest of the session. Same reason
|
# behind a poisoned lock for the rest of the session. Same reason
|
||||||
# `proton-drive-linux` uses it.
|
# `proton-drive-linux` uses it.
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
# Media keys and the desktop's media widget: MPRIS on Linux, SMTC on Windows.
|
||||||
|
# `dbus` rather than `zbus` because the keyring's Secret Service store already
|
||||||
|
# links libdbus, so this adds no second D-Bus stack.
|
||||||
|
souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] }
|
||||||
|
# Holding the screensaver off while a film plays. mpv does that for a window of
|
||||||
|
# its own, but the picture here is drawn into ours, and mpv has no window to
|
||||||
|
# hold it for.
|
||||||
|
dbus = "0.9"
|
||||||
|
windows-sys = "0.61"
|
||||||
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
|
tauri-winrt-notification = "0.8"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.12"
|
lru = "0.18"
|
||||||
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the
|
||||||
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
# stream layer needs a `dyn` block source so its cache, ring and read-ahead can
|
||||||
# be tested against a fake without a network or an account.
|
# be tested against a fake without a network or an account.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# 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, the Library, Title, Shares and Settings
|
||||||
|
redesigns, Inter, and screenshot tests. Not yet verified on a device; the
|
||||||
|
host gate (ktlint, lint, unit and screenshot tests) and CI's build job pass.
|
||||||
|
|
||||||
|
Open, from section 2: per-screen app bars and edge-to-edge under the status
|
||||||
|
bar, skeleton loading and generated placeholder art, a search icon that
|
||||||
|
expands, a language picker, Settings sub-pages, the Downloads page, light
|
||||||
|
theme and tablet screenshots, `ListDetailPaneScaffold` on tablets, shared
|
||||||
|
element transitions and predictive back, and a long-press Change match (the
|
||||||
|
title page has it).
|
||||||
|
|
||||||
|
## 0. CI (done)
|
||||||
|
|
||||||
|
Every `Android` workflow run since August failed in `:app:buildRustHost`:
|
||||||
|
`pstr-core` pulls `keyring` → `dbus-secret-service` → `libdbus-sys`, which needs
|
||||||
|
`libdbus-1-dev` on the host. Because no run ever succeeded, no cache was ever
|
||||||
|
saved, so each run started from nothing.
|
||||||
|
|
||||||
|
- `libdbus-1-dev` added to both jobs' apt install.
|
||||||
|
- `Swatinem/rust-cache` shares one key between the two jobs and saves on failure.
|
||||||
|
- `android/app/build/generated/mpv` is cached on the hash of
|
||||||
|
`scripts/build-libmpv-android.sh`; the script writes a `STAMP` (revision plus
|
||||||
|
its own hash) last and skips the whole libmpv build when the stamp matches.
|
||||||
|
|
||||||
|
Later runs also needed meson 1.12.1 from PyPI (noble's 1.3.2 is too old for
|
||||||
|
the pinned fontconfig), libmpv built and cached in its own steps so a later
|
||||||
|
failure keeps it, and lint's `OldTargetApi` made informational. A cached run
|
||||||
|
restores libmpv in under a minute.
|
||||||
|
|
||||||
|
## 1. Desktop parity
|
||||||
|
|
||||||
|
Ordered by value on a phone.
|
||||||
|
|
||||||
|
1. **Open share links from outside the app.** The manifest has only the
|
||||||
|
launcher intent. Add an `ACTION_VIEW` filter for `drive.proton.me/urls/*` and
|
||||||
|
an `ACTION_SEND` (`text/plain`) filter; both open the add-share dialog with
|
||||||
|
the link filled in. Desktop: `4a54af3`, `53a1d0a`.
|
||||||
|
2. **Streaming cache budget.** Android runs on the fixed 4 GiB default
|
||||||
|
(`DiskCacheConfig::DEFAULT_BUDGET_BYTES`), large for a phone. Expose
|
||||||
|
`DiskCache::set_budget` through `pstr-android`, add a 1–16 GiB choice with a
|
||||||
|
usage readout under Storage. Desktop: `a432a93`.
|
||||||
|
3. **Hardware-decoding switch.** `pstr_mpv.cpp` hard-codes
|
||||||
|
`hwdec=auto-copy-safe`; a device with green frames has no way out. A
|
||||||
|
Settings toggle, applied from the next file.
|
||||||
|
4. **Episode list in the player.** A bottom sheet (portrait) or side panel
|
||||||
|
(landscape) with the title's episodes, opened on the playing season and
|
||||||
|
scrolled to the playing episode. Desktop: `7e734db`.
|
||||||
|
5. **Long-press menu on tiles.** Play/resume, mark watched or unwatched,
|
||||||
|
download, change match, remove from Continue watching, in a
|
||||||
|
`ModalBottomSheet`. Desktop: `c5808a5`.
|
||||||
|
6. **Undo on snackbars** for marking a title and leaving Continue watching.
|
||||||
|
`AppUiState.message` is a bare string today; it needs an action. Desktop:
|
||||||
|
`79c3f68`.
|
||||||
|
7. **History tab.** Needs a bridge call over the `pstr-core::library` query
|
||||||
|
the desktop page uses. Desktop: `e7eeaa5`.
|
||||||
|
8. **Buffered range on the seek bar** from mpv's `demuxer-cache-time`.
|
||||||
|
9. **Up-next card** with the next episode's still and a countdown ring instead
|
||||||
|
of "Up next in 7s" text.
|
||||||
|
10. **Featured banner** at the top of the library. Desktop: `7d03736`.
|
||||||
|
11. **Bundled Inter** in `ui/theme/Type.kt`, so both clients read the same.
|
||||||
|
Desktop: `558446b`.
|
||||||
|
|
||||||
|
## 2. UI recommendations
|
||||||
|
|
||||||
|
### Everywhere
|
||||||
|
|
||||||
|
- **Colours stay the theme's.** The red is the Persona theme's accent and the
|
||||||
|
card tint comes from the shared palette, so the redesign changes layout and
|
||||||
|
button hierarchy, not colours.
|
||||||
|
- **One button hierarchy.** At most one filled accent button per screen;
|
||||||
|
secondary actions tonal or outlined; destructive actions behind a menu and a
|
||||||
|
confirmation, never a filled red button in a list row.
|
||||||
|
- **Edge-to-edge, per-screen titles.** The "proton-stream" app bar on every
|
||||||
|
page wastes a band of height. Library uses a collapsing large title; Title
|
||||||
|
pages draw the backdrop under the status bar; other tabs use their name.
|
||||||
|
- **Skeletons instead of spinners** for library and title loading, and
|
||||||
|
generated placeholder art (gradient from the accent plus the title's
|
||||||
|
initials) instead of a black card with a play triangle (Akira).
|
||||||
|
- **Pull-to-refresh** (`PullToRefreshBox`) replaces the top-bar refresh icon,
|
||||||
|
with crawl progress shown in the indicator area.
|
||||||
|
- **Split `ui/ProtonStreamApp.kt`** (1665 lines) into one file per screen
|
||||||
|
before the work below; every item touches it.
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
- Poster grid (2:3, `posterUrl`) at three columns on a phone, title under the
|
||||||
|
poster with no card body. The current 210 dp cropped backdrops fit two titles
|
||||||
|
per screen.
|
||||||
|
- Continue watching: 16:9 still with the progress bar laid over the bottom
|
||||||
|
edge of the image and "S03E11 · 12 min left" under it.
|
||||||
|
- Search becomes an icon in the app bar that expands into an M3 `SearchBar`,
|
||||||
|
instead of a permanent field above everything.
|
||||||
|
- Fix "1 episodes" (line ~507; the season header already pluralises).
|
||||||
|
|
||||||
|
### Title page
|
||||||
|
|
||||||
|
- Full-bleed backdrop with a gradient scrim, poster, title and meta chips
|
||||||
|
(year, score, genres) over it, collapsing as the page scrolls.
|
||||||
|
- Synopsis clamped to three lines with "More".
|
||||||
|
- One full-width primary button, "Resume S03E11" or "Play S01E01", then a row
|
||||||
|
of icon actions: download, mark watched, and a ⋮ menu holding Start over,
|
||||||
|
Change match and More on AniList. Today five buttons of three styles wrap
|
||||||
|
over three lines.
|
||||||
|
- Drop the "Preferred tracks" form and its Save button from this page. The
|
||||||
|
player already remembers tracks by name per title (`d3c5f07`); expose any
|
||||||
|
override from the player's track chooser.
|
||||||
|
- Season picker as a dropdown chip or tabs, not stacked accordions; a 40
|
||||||
|
episode title is one long scroll today.
|
||||||
|
- Episode rows: 16:9 still with progress overlay, "1 · Mother and Children"
|
||||||
|
(provider name, else "Episode 1") rather than the raw file name
|
||||||
|
`[Judas] Oshi no Ko - S01E01.mkv`, runtime or air date as supporting text,
|
||||||
|
watched and download state as small trailing icons. The red `S01E01` label
|
||||||
|
above each row goes.
|
||||||
|
|
||||||
|
### Shares
|
||||||
|
|
||||||
|
- `ListItem` rows: name, "12 titles · crawled 2 h ago" or the failure, and a ⋮
|
||||||
|
menu with Refresh, Re-enter link and Remove (confirmed). The current row
|
||||||
|
wraps "Custom password stored securely" over three lines beside three
|
||||||
|
differently styled actions.
|
||||||
|
- "Add share" as an extended FAB, which is also where the share-link intent
|
||||||
|
from parity item 1 lands.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- Grouped `ListItem` rows (headline, one line of supporting text, trailing
|
||||||
|
switch), with the long explanations moved into the supporting line or an
|
||||||
|
info dialog. Today each toggle carries a paragraph.
|
||||||
|
- Split into sub-pages once storage and decoding settings land: Playback,
|
||||||
|
Downloads & storage, Appearance, Metadata, About.
|
||||||
|
- Language fields become a picker dialog over a language list, not free text.
|
||||||
|
- Storage shows a usage bar (cache, offline episodes) above the budget choice.
|
||||||
|
|
||||||
|
### Robustness
|
||||||
|
|
||||||
|
- Screenshot tests (Roborazzi, JVM, no device) for each screen in light and
|
||||||
|
dark, phone and tablet, at font scale 1.0 and 1.5 — the redesign changes
|
||||||
|
every screen, and `docs/TESTING.md` has no visual layer today.
|
||||||
|
- 48 dp minimum touch targets and content descriptions on the new icon-only
|
||||||
|
actions.
|
||||||
|
- Tablet: `ListDetailPaneScaffold` for library + title, since
|
||||||
|
`NavigationSuiteScaffold` already switches to a rail there.
|
||||||
|
- Shared-element transition from a poster to its title page, and predictive
|
||||||
|
back.
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. CI green (section 0).
|
||||||
|
2. Split `ProtonStreamApp.kt`; add screenshot tests for the current screens.
|
||||||
|
3. Parity 1–3 (small, phone-specific fixes).
|
||||||
|
4. Button hierarchy and Inter — every later screen inherits them.
|
||||||
|
5. Title page, then Library, then Shares and Settings.
|
||||||
|
6. Parity 4–10.
|
||||||
@@ -35,8 +35,12 @@ Its toolchain, emulator, signing, and release instructions are in [`docs/ANDROID
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -p pstr-app # the app
|
cargo run --release -p pstr-app # the app
|
||||||
|
proton-stream 'https://drive.proton.me/urls/TOKEN#fragment' # open the Add form with a link
|
||||||
```
|
```
|
||||||
|
|
||||||
|
One window at a time: a second launch brings the open window forward and
|
||||||
|
hands it any share link it was given, then exits.
|
||||||
|
|
||||||
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
The `pstr` CLI does the same work headlessly, and is what tells you which layer
|
||||||
is broken:
|
is broken:
|
||||||
|
|
||||||
@@ -103,11 +107,21 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing.
|
|||||||
a local HTTP server: there is no port for another process on the machine to ask
|
a local HTTP server: there is no port for another process on the machine to ask
|
||||||
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
for the plaintext, and mpv is handed an opaque token rather than a URL that
|
||||||
would put a share secret in its log and window title.
|
would put a share secret in its log and window title.
|
||||||
- The block cache holds decrypted content. It lives in your cache directory and
|
- The block cache holds decrypted content. It lives in your cache directory,
|
||||||
can be deleted at any time the app is not running.
|
can be deleted at any time the app is not running, and can be sized or
|
||||||
|
emptied from Settings → Storage.
|
||||||
|
- A share link given as a command-line argument is visible to other users of
|
||||||
|
the machine in the process list while that launch runs, and may land in
|
||||||
|
shell history. Pasting it into the window (Ctrl+V) does neither.
|
||||||
|
- The desktop's media widget is handed the title, the episode and a `file://`
|
||||||
|
path to artwork already on disk — never a provider URL, so the shell never
|
||||||
|
contacts a metadata provider on its own.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The shared Rust crates and desktop application are MIT licensed. The Android
|
The shared Rust crates and desktop application are MIT licensed. The Android
|
||||||
application and its combined native distribution are GPL-3.0-or-later; see
|
application and its combined native distribution are GPL-3.0-or-later; see
|
||||||
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices.
|
||||||
|
The desktop application embeds the Inter typeface, under the SIL Open Font
|
||||||
|
License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)),
|
||||||
|
and the Phosphor icon font, under MIT.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -269,6 +273,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 +301,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,7 @@
|
|||||||
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: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 +29,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 +39,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,82 @@ 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,
|
||||||
|
)
|
||||||
|
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 +1018,64 @@ 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,
|
||||||
|
)
|
||||||
|
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,40 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
|
|
||||||
|
@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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.AccentProgress
|
||||||
|
import io.narl.protonstream.ui.theme.QuietButton
|
||||||
|
import io.narl.protonstream.ui.theme.TonalButton
|
||||||
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DownloadsScreen(
|
||||||
|
state: AppUiState,
|
||||||
|
onRemove: (uniffi.pstr_android.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.canonicalName ?: title.name)
|
||||||
|
}
|
||||||
|
}.toMap()
|
||||||
|
state.offline
|
||||||
|
.groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" }
|
||||||
|
.toSortedMap()
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text("Offline downloads", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
if (retained.isEmpty() && state.offline.isEmpty()) {
|
||||||
|
item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") }
|
||||||
|
}
|
||||||
|
// 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 { (show, files) ->
|
||||||
|
item(key = "group/$show") {
|
||||||
|
Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp))
|
||||||
|
Text(
|
||||||
|
"${files.size} ${if (files.size == 1) "episode" else "episodes"} · " +
|
||||||
|
formatBytes(files.sumOf { it.size }),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(files, key = { "${it.shareId}/${it.linkId}" }) { file ->
|
||||||
|
val episode = file.episode
|
||||||
|
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(formatBytes(file.size))
|
||||||
|
}
|
||||||
|
TonalButton(onClick = { onRemove(file) }) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(retained, key = { "${it.shareId}/${it.linkId}" }) { download ->
|
||||||
|
val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f
|
||||||
|
Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(download.label, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(download.status.replaceFirstChar { it.uppercase() })
|
||||||
|
if (download.total > 0L) {
|
||||||
|
AccentProgress(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
if (download.status == RetainedDownload.STATUS_RUNNING ||
|
||||||
|
download.status == RetainedDownload.STATUS_QUEUED
|
||||||
|
) {
|
||||||
|
TonalButton(onClick = { onPause(download) }) { Text("Pause") }
|
||||||
|
} else {
|
||||||
|
TonalButton(onClick = { onResume(download) }) { Text("Resume") }
|
||||||
|
}
|
||||||
|
QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
EmptyState("Nothing watched yet", "Episodes you play show up here, newest first.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(padding), 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,
|
||||||
|
)
|
||||||
|
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,453 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.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.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.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,
|
||||||
|
) {
|
||||||
|
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) }
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
// A filled pill rather than an outlined form field: search is how the
|
||||||
|
// page is navigated, not a value being entered.
|
||||||
|
TextField(
|
||||||
|
value = state.query,
|
||||||
|
onValueChange = onSearch,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
singleLine = true,
|
||||||
|
shape = CircleShape,
|
||||||
|
placeholder = { Text("Search library") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (state.query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearch("") }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Pull to refresh, the gesture every list on a phone answers to; the
|
||||||
|
// icon in the bar stays for a viewer who does not know it.
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = state.refreshing,
|
||||||
|
onRefresh = onRefresh,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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("Library", 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 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,
|
||||||
|
)
|
||||||
|
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.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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
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) }
|
||||||
|
// Rows with one line of explanation each, in groups, rather than a toggle
|
||||||
|
// followed by a paragraph: the page read as a document to get through
|
||||||
|
// before reaching the switch that was wanted.
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())) {
|
||||||
|
SettingsGroup("Playback")
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
// Language tags, not a picker: which languages exist is a property
|
||||||
|
// of each file, and a list built from one episode is wrong for the
|
||||||
|
// next. A show that has been given its own choice keeps it.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
LanguageField("Audio language", current.audioLanguage, Modifier.weight(1f)) { tag ->
|
||||||
|
update { it.copy(audioLanguage = tag) }
|
||||||
|
}
|
||||||
|
LanguageField("Subtitle language", current.subtitleLanguage, Modifier.weight(1f)) { tag ->
|
||||||
|
update { it.copy(subtitleLanguage = tag) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Tags as they appear in the file, such as jpn or eng",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsGroup("Downloads and storage")
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsGroup("Appearance")
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() }
|
||||||
|
|
||||||
|
SettingsGroup("Metadata")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsGroup("About")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group's heading, in the accent, the way Android's own settings mark them. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsGroup(label: String) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One language tag, committed as it is typed.
|
||||||
|
*
|
||||||
|
* Blank is a real answer and means "no preference" — the bridge stores it as
|
||||||
|
* absent, which is what leaves the choice to the container's own default track.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LanguageField(label: String, value: String?, modifier: Modifier = Modifier, onChange: (String?) -> Unit) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value.orEmpty(),
|
||||||
|
onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) },
|
||||||
|
label = { Text(label) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
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) }
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
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)) {
|
||||||
|
item(key = "heading") {
|
||||||
|
Text(
|
||||||
|
"Proton Drive public links",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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,562 @@
|
|||||||
|
package io.narl.protonstream.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.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) }
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
// 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() }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
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.fillMaxWidth().aspectRatio(16f / 9f)) {
|
||||||
|
RemoteArtwork(
|
||||||
|
title.backdropUrl ?: title.posterUrl,
|
||||||
|
title.canonicalName ?: title.name,
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
fallback = title.thumbnailSource,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.45f to Color.Transparent,
|
||||||
|
1f to MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.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 = 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 = 16.dp, end = 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 ->
|
||||||
|
EpisodeRow(
|
||||||
|
episode = episode,
|
||||||
|
numbering = episode.numbering(shown.number, position),
|
||||||
|
position = position,
|
||||||
|
download = downloads.firstOrNull {
|
||||||
|
it.shareId == episode.shareId && it.linkId == episode.linkId
|
||||||
|
},
|
||||||
|
playerReady = playerReady,
|
||||||
|
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
||||||
|
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
||||||
|
onPause = { DownloadCoordinator.pause(context, it) },
|
||||||
|
onResume = { DownloadCoordinator.resume(context, it) },
|
||||||
|
onSetWatched = { onSetWatched(episode, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showMatch) {
|
||||||
|
ChangeMatchDialog(
|
||||||
|
title = title,
|
||||||
|
onDismiss = { showMatch = false },
|
||||||
|
onChanged = { showMatch = false; onMetadataChanged() },
|
||||||
|
onError = onPreferenceError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One 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,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private 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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes(
|
|||||||
/** The radius `pstr-app` draws everything at. */
|
/** The radius `pstr-app` draws everything at. */
|
||||||
internal const val CORNER = 8f
|
internal const val CORNER = 8f
|
||||||
|
|
||||||
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
|
/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */
|
||||||
internal const val CORNER_TIGHT = 4f
|
internal const val CORNER_TIGHT = 4f
|
||||||
|
|
||||||
/** A bottom sheet, which has no desktop counterpart. */
|
/** A bottom sheet, which has no desktop counterpart. */
|
||||||
|
|||||||
@@ -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,32 @@
|
|||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
<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="64" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-10" 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="64" android:pivotY="62"
|
||||||
|
android:scaleX="0.6923" android:scaleY="0.6923"
|
||||||
|
android:translateX="-10" 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,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,201 @@
|
|||||||
|
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.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.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 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
|
compose.setContent {
|
||||||
|
ProtonStreamTheme {
|
||||||
|
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 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 `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())
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 nothing offline`() = capture("downloads-empty") {
|
||||||
|
DownloadsScreen(LIBRARY, {}, {}, {}, {}, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
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: 33 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 160 KiB |
@@ -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),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,13 +1130,27 @@ impl AndroidEngine {
|
|||||||
title_key: String,
|
title_key: String,
|
||||||
preferences: TrackPreferencesRecord,
|
preferences: TrackPreferencesRecord,
|
||||||
) -> Result<(), BridgeError> {
|
) -> Result<(), BridgeError> {
|
||||||
|
let catalog = self.catalog.lock();
|
||||||
|
let existing = catalog
|
||||||
|
.title_track_prefs(&title_key)
|
||||||
|
.map_err(BridgeError::from_display)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let audio_language = normalized_language(preferences.audio_language);
|
||||||
|
let subtitle_language = normalized_language(preferences.subtitle_language);
|
||||||
|
// This bridge carries languages only. A track title stored beside one
|
||||||
|
// stays for as long as the language it was picked under does.
|
||||||
let preferences = TitleTrackPrefs {
|
let preferences = TitleTrackPrefs {
|
||||||
audio_language: normalized_language(preferences.audio_language),
|
audio_title: existing
|
||||||
subtitle_language: normalized_language(preferences.subtitle_language),
|
.audio_title
|
||||||
|
.filter(|_| existing.audio_language == audio_language),
|
||||||
|
subtitle_title: existing
|
||||||
|
.subtitle_title
|
||||||
|
.filter(|_| existing.subtitle_language == subtitle_language),
|
||||||
|
audio_language,
|
||||||
|
subtitle_language,
|
||||||
subtitles: preferences.subtitles,
|
subtitles: preferences.subtitles,
|
||||||
};
|
};
|
||||||
self.catalog
|
catalog
|
||||||
.lock()
|
|
||||||
.set_title_track_prefs(&title_key, &preferences)
|
.set_title_track_prefs(&title_key, &preferences)
|
||||||
.map_err(BridgeError::from_display)
|
.map_err(BridgeError::from_display)
|
||||||
}
|
}
|
||||||
@@ -1265,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> {
|
||||||
@@ -1709,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)?;
|
||||||
|
|||||||
@@ -2,3 +2,9 @@
|
|||||||
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
||||||
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||||
disable_java_cleaner = true
|
disable_java_cleaner = true
|
||||||
|
|
||||||
|
[bindings.swift]
|
||||||
|
# The iOS client (`ios/`) compiles the generated file straight into the app
|
||||||
|
# target and reaches the C side through its bridging header, so the names are
|
||||||
|
# what that header and `ios/project.yml` expect.
|
||||||
|
module_name = "PstrBridge"
|
||||||
@@ -24,8 +24,25 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
eframe.workspace = true
|
eframe.workspace = true
|
||||||
egui.workspace = true
|
egui.workspace = true
|
||||||
|
egui-phosphor.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
directories.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
souvlaki.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
skrifa.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
dbus.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
# Named pipes, for handing a second launch's request to the open window.
|
||||||
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] }
|
||||||
|
tauri-winrt-notification.workspace = true
|
||||||
|
raw-window-handle = "0.6"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -92,7 +92,12 @@ impl Harness {
|
|||||||
.context("eframe was built without a glow context")?;
|
.context("eframe was built without a glow context")?;
|
||||||
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
// SAFETY: `CreationContext` is handed to us on the UI thread with the
|
||||||
// context current, which is the same guarantee `App::ui` gives.
|
// context current, which is the same guarantee `App::ui` gives.
|
||||||
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
|
let video = unsafe {
|
||||||
|
VideoSurface::new(Arc::clone(&player), gl, {
|
||||||
|
let ctx = cc.egui_ctx.clone();
|
||||||
|
move || ctx.request_repaint()
|
||||||
|
})
|
||||||
|
}?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
player,
|
player,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Settings only the desktop app has.
|
||||||
|
//!
|
||||||
|
//! Kept out of [`pstr_core::prefs::PlaybackPrefs`] on purpose: that one is
|
||||||
|
//! shared with Android through a bridge record, and a field Android neither
|
||||||
|
//! shows nor keeps would be reset to its default every time Android saved.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the settings page offers for the streaming cache, in GiB.
|
||||||
|
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
// A file from an older version, or edited by hand, missing a field: the rest
|
||||||
|
// still counts, and the missing one takes its default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DesktopPrefs {
|
||||||
|
/// Let mpv decode on the GPU. Off is for a driver that shows green frames
|
||||||
|
/// or nothing; it costs CPU, not correctness.
|
||||||
|
pub hardware_decoding: bool,
|
||||||
|
/// How much decrypted video the disk cache may keep.
|
||||||
|
pub cache_gib: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DesktopPrefs {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hardware_decoding: true,
|
||||||
|
cache_gib: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopPrefs {
|
||||||
|
pub fn cache_bytes(&self) -> u64 {
|
||||||
|
u64::from(self.cache_gib.clamp(1, 64)) << 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefs and where they live.
|
||||||
|
pub struct DesktopPrefsFile {
|
||||||
|
path: PathBuf,
|
||||||
|
/// False when the file was there and would not parse. Such a file is never
|
||||||
|
/// overwritten — it is far more likely a bug than something to replace —
|
||||||
|
/// so changes then last only for the session.
|
||||||
|
writable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopPrefsFile {
|
||||||
|
pub fn load(path: PathBuf) -> (Self, DesktopPrefs) {
|
||||||
|
match pstr_core::config::read_json::<DesktopPrefs>(&path) {
|
||||||
|
Ok(prefs) => (
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
writable: true,
|
||||||
|
},
|
||||||
|
prefs.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("read the desktop settings: {error}");
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
writable: false,
|
||||||
|
},
|
||||||
|
DesktopPrefs::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, prefs: &DesktopPrefs) -> pstr_core::Result<()> {
|
||||||
|
if !self.writable {
|
||||||
|
tracing::warn!(
|
||||||
|
"not saving the desktop settings over {}, which would not parse",
|
||||||
|
self.path.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
pstr_core::config::write_json(&self.path, prefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_missing_a_field_keeps_the_rest() {
|
||||||
|
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
|
||||||
|
assert_eq!(prefs.cache_gib, 8);
|
||||||
|
assert!(prefs.hardware_decoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_file_is_never_overwritten() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
|
||||||
|
std::fs::write(&path, "{ not json").unwrap();
|
||||||
|
let (file, prefs) = DesktopPrefsFile::load(path.clone());
|
||||||
|
assert_eq!(prefs, DesktopPrefs::default());
|
||||||
|
file.save(&DesktopPrefs::default()).unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! Keeping the screen on while a film plays.
|
||||||
|
//!
|
||||||
|
//! mpv does this itself for a window of its own (`stop-screensaver`), but here
|
||||||
|
//! the picture is drawn into the app's window through the render API, and mpv
|
||||||
|
//! has no window to hold the screensaver off for. So the app asks.
|
||||||
|
//!
|
||||||
|
//! Best effort, like the media controls: a desktop that does not answer means
|
||||||
|
//! the screen may blank during a film, which is what happened before this.
|
||||||
|
|
||||||
|
/// Holds the screensaver off while [`Inhibitor::set`] says so.
|
||||||
|
pub struct Inhibitor {
|
||||||
|
held: bool,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
orders: Option<std::sync::mpsc::Sender<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inhibitor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
held: false,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
orders: linux::spawn(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold or release. Called every frame; acts only on a change.
|
||||||
|
pub fn set(&mut self, hold: bool) {
|
||||||
|
if hold == self.held {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.held = hold;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(orders) = &self.orders {
|
||||||
|
let _ = orders.send(hold);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
windows::set(hold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Inhibitor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Inhibitor {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux {
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use dbus::Path;
|
||||||
|
use dbus::arg::PropMap;
|
||||||
|
use dbus::blocking::Connection;
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
const REASON: &str = "Playing a video";
|
||||||
|
|
||||||
|
/// What is holding the screensaver off, and how to let go of it.
|
||||||
|
enum Hold {
|
||||||
|
/// The desktop portal: released by closing the request it returned.
|
||||||
|
Portal(Path<'static>),
|
||||||
|
/// The older `org.freedesktop.ScreenSaver`: released by its cookie.
|
||||||
|
ScreenSaver(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thread of its own, holding the session bus connection. The hold is
|
||||||
|
/// tied to that connection — closing it releases the hold — so it has to
|
||||||
|
/// outlive any one call, and a D-Bus call that stalls must not stall a
|
||||||
|
/// frame.
|
||||||
|
pub fn spawn() -> Option<Sender<bool>> {
|
||||||
|
let (orders, receiver) = channel::<bool>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("inhibit".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let connection = match Connection::new_session() {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!("no session bus to inhibit the screensaver on: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut hold = None;
|
||||||
|
for wanted in receiver {
|
||||||
|
match (wanted, hold.take()) {
|
||||||
|
(true, None) => hold = acquire(&connection),
|
||||||
|
(false, Some(held)) => release(&connection, held),
|
||||||
|
(_, held) => hold = held,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(held) = hold {
|
||||||
|
release(&connection, held);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("inhibit thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire(connection: &Connection) -> Option<Hold> {
|
||||||
|
// The portal first: it is what works under Flatpak and on GNOME, which
|
||||||
|
// does not implement the screensaver interface's `Inhibit`.
|
||||||
|
let portal = connection.with_proxy(
|
||||||
|
"org.freedesktop.portal.Desktop",
|
||||||
|
"/org/freedesktop/portal/desktop",
|
||||||
|
TIMEOUT,
|
||||||
|
);
|
||||||
|
let mut options = PropMap::new();
|
||||||
|
options.insert(
|
||||||
|
"reason".into(),
|
||||||
|
dbus::arg::Variant(Box::new(REASON.to_owned())),
|
||||||
|
);
|
||||||
|
// 8 is "idle": the screen blanking or locking for want of input.
|
||||||
|
let answer: Result<(Path<'static>,), _> = portal.method_call(
|
||||||
|
"org.freedesktop.portal.Inhibit",
|
||||||
|
"Inhibit",
|
||||||
|
("", 8u32, options),
|
||||||
|
);
|
||||||
|
match answer {
|
||||||
|
Ok((request,)) => return Some(Hold::Portal(request)),
|
||||||
|
Err(error) => tracing::debug!("portal inhibit: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let screensaver = connection.with_proxy(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"/org/freedesktop/ScreenSaver",
|
||||||
|
TIMEOUT,
|
||||||
|
);
|
||||||
|
let answer: Result<(u32,), _> = screensaver.method_call(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"Inhibit",
|
||||||
|
("proton-stream", REASON),
|
||||||
|
);
|
||||||
|
match answer {
|
||||||
|
Ok((cookie,)) => Some(Hold::ScreenSaver(cookie)),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::debug!("screensaver inhibit: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(connection: &Connection, hold: Hold) {
|
||||||
|
let result: Result<(), dbus::Error> = match hold {
|
||||||
|
Hold::Portal(request) => connection
|
||||||
|
.with_proxy("org.freedesktop.portal.Desktop", request, TIMEOUT)
|
||||||
|
.method_call("org.freedesktop.portal.Request", "Close", ()),
|
||||||
|
Hold::ScreenSaver(cookie) => connection
|
||||||
|
.with_proxy(
|
||||||
|
"org.freedesktop.ScreenSaver",
|
||||||
|
"/org/freedesktop/ScreenSaver",
|
||||||
|
TIMEOUT,
|
||||||
|
)
|
||||||
|
.method_call("org.freedesktop.ScreenSaver", "UnInhibit", (cookie,)),
|
||||||
|
};
|
||||||
|
if let Err(error) = result {
|
||||||
|
tracing::debug!("release the screensaver: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows {
|
||||||
|
use windows_sys::Win32::System::Power::{
|
||||||
|
ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, SetThreadExecutionState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn set(hold: bool) {
|
||||||
|
let flags = if hold {
|
||||||
|
ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED
|
||||||
|
} else {
|
||||||
|
ES_CONTINUOUS
|
||||||
|
};
|
||||||
|
// SAFETY: takes flags by value and touches nothing of ours.
|
||||||
|
unsafe {
|
||||||
|
SetThreadExecutionState(flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
//! One window at a time.
|
||||||
|
//!
|
||||||
|
//! A second launch hands what it was started with — a share link, or nothing
|
||||||
|
//! — to the window already open, and exits. Without this, a second click on
|
||||||
|
//! the launcher opened a second window on the same catalog: two engines
|
||||||
|
//! writing the same watch state, two crawls of the same shares, and two films
|
||||||
|
//! playing over each other.
|
||||||
|
//!
|
||||||
|
//! The channel between the two is a Unix socket in the user's runtime
|
||||||
|
//! directory, or a named pipe on Windows. Anything that goes wrong setting it
|
||||||
|
//! up is logged and the app runs on without it: a second window is a nuisance,
|
||||||
|
//! and a launch that refuses to start because of this is a bug.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
|
||||||
|
use pstr_core::config::AppDirs;
|
||||||
|
|
||||||
|
/// What a later launch asks of the window already open.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Request {
|
||||||
|
/// Come to the front.
|
||||||
|
Raise,
|
||||||
|
/// Come to the front, with this share link in the Add form.
|
||||||
|
AddShare(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
/// What a launch was started with: the first argument that is a share
|
||||||
|
/// link, so `proton-stream 'https://drive.proton.me/urls/…#…'` adds it.
|
||||||
|
pub fn from_args(args: impl IntoIterator<Item = String>) -> Self {
|
||||||
|
args.into_iter()
|
||||||
|
.map(|arg| arg.trim().to_owned())
|
||||||
|
.find(|arg| pstr_core::shares::share_token(arg).is_ok())
|
||||||
|
.map_or(Self::Raise, Self::AddShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line on the wire.
|
||||||
|
fn encode(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Raise => "raise\n".to_owned(),
|
||||||
|
Self::AddShare(url) => format!("add {url}\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other end of [`Request::encode`]. Anything else is ignored rather
|
||||||
|
/// than guessed at — and a link that is not a share link is not one to
|
||||||
|
/// put in the form.
|
||||||
|
fn decode(line: &str) -> Option<Self> {
|
||||||
|
let line = line.trim();
|
||||||
|
if line == "raise" {
|
||||||
|
return Some(Self::Raise);
|
||||||
|
}
|
||||||
|
let url = line.strip_prefix("add ")?.trim();
|
||||||
|
pstr_core::shares::share_token(url)
|
||||||
|
.is_ok()
|
||||||
|
.then(|| Self::AddShare(url.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a launch spends handing its request over before giving up on it.
|
||||||
|
const HAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
/// The most a request may be. A share link is a few hundred bytes.
|
||||||
|
const MOST: u64 = 8 * 1024;
|
||||||
|
|
||||||
|
/// The outcome of [`claim`].
|
||||||
|
pub enum Claim {
|
||||||
|
/// This is the window. The listener answers later launches.
|
||||||
|
First(Listener),
|
||||||
|
/// A window was already open and has the request. This launch is done.
|
||||||
|
Handed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Become the one window, or hand `request` to the one there is.
|
||||||
|
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
|
||||||
|
platform::claim(runtime, dirs, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests from later launches, until the process ends.
|
||||||
|
pub struct Listener {
|
||||||
|
inner: Option<platform::Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Listener {
|
||||||
|
/// Start answering. `first` is this launch's own request, which arrives
|
||||||
|
/// through the same channel as the rest so there is one place to act on it.
|
||||||
|
pub fn serve(
|
||||||
|
self,
|
||||||
|
runtime: &tokio::runtime::Runtime,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
first: Request,
|
||||||
|
) -> Receiver<Request> {
|
||||||
|
let (sender, receiver) = channel();
|
||||||
|
if first != Request::Raise {
|
||||||
|
let _ = sender.send(first);
|
||||||
|
}
|
||||||
|
if let Some(inner) = self.inner {
|
||||||
|
platform::serve(
|
||||||
|
inner,
|
||||||
|
runtime,
|
||||||
|
Deliver {
|
||||||
|
sender,
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
receiver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a received request goes: the app's queue, and a repaint so it is
|
||||||
|
/// acted on now rather than when the pointer next moves.
|
||||||
|
struct Deliver {
|
||||||
|
sender: Sender<Request>,
|
||||||
|
ctx: egui::Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deliver {
|
||||||
|
fn line(&self, text: &str) {
|
||||||
|
let Some(request) = Request::decode(text) else {
|
||||||
|
tracing::debug!("instance: ignored a request that did not parse");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = self.sender.send(request);
|
||||||
|
self.ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod platform {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
|
||||||
|
|
||||||
|
pub struct Inner(UnixListener);
|
||||||
|
|
||||||
|
/// In the runtime directory where there is one: it is the user's alone and
|
||||||
|
/// emptied at logout, which is exactly the life a socket should have.
|
||||||
|
fn socket(dirs: &AppDirs) -> PathBuf {
|
||||||
|
directories::BaseDirs::new()
|
||||||
|
.and_then(|base| base.runtime_dir().map(std::path::Path::to_path_buf))
|
||||||
|
.unwrap_or_else(|| dirs.cache.clone())
|
||||||
|
.join("io.narl.proton-stream.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn claim(_: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
|
||||||
|
let path = socket(dirs);
|
||||||
|
if let Ok(mut stream) = UnixStream::connect(&path) {
|
||||||
|
let _ = stream.set_write_timeout(Some(HAND_TIMEOUT));
|
||||||
|
match stream.write_all(request.encode().as_bytes()) {
|
||||||
|
Ok(()) => return Claim::Handed,
|
||||||
|
Err(error) => tracing::warn!("instance: hand over to the open window: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nobody answered, so a socket file still there is left over from a
|
||||||
|
// window that did not exit cleanly.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let listener = match UnixListener::bind(&path) {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("instance: listen at {}: {error}", path.display());
|
||||||
|
return Claim::First(Listener { inner: None });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The runtime directory is private already; the cache fallback is
|
||||||
|
// not necessarily, and a share link is a secret.
|
||||||
|
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||||
|
Claim::First(Listener {
|
||||||
|
inner: Some(Inner(listener)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(inner: Inner, _: &tokio::runtime::Runtime, deliver: Deliver) {
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("instance".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for stream in inner.0.incoming() {
|
||||||
|
let Ok(stream) = stream else { continue };
|
||||||
|
let _ = stream.set_read_timeout(Some(HAND_TIMEOUT));
|
||||||
|
let mut text = String::new();
|
||||||
|
if stream.take(MOST).read_to_string(&mut text).is_ok() {
|
||||||
|
deliver.line(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
tracing::warn!("instance thread: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod platform {
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
|
||||||
|
|
||||||
|
use super::{AppDirs, Claim, Deliver, HAND_TIMEOUT, Listener, MOST, Request};
|
||||||
|
|
||||||
|
pub struct Inner {
|
||||||
|
name: String,
|
||||||
|
first: NamedPipeServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pipe's name. Pipe names are machine-wide, so a fixed one could be
|
||||||
|
/// taken first by another account on the same machine, which would then
|
||||||
|
/// be handed this user's share links. The name carries a random part kept
|
||||||
|
/// in this user's own config directory, which nobody else can read.
|
||||||
|
fn pipe(dirs: &AppDirs) -> String {
|
||||||
|
let path = dirs.config.join("instance.key");
|
||||||
|
let key = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|key| key.trim().to_owned())
|
||||||
|
.filter(|key| key.len() == 32 && key.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
// `RandomState` is seeded from the OS's random source, once
|
||||||
|
// per process and stepped per instance after that.
|
||||||
|
let mut key = String::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let word = std::collections::hash_map::RandomState::new()
|
||||||
|
.build_hasher()
|
||||||
|
.finish();
|
||||||
|
key.push_str(&format!("{word:016x}"));
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::write(&path, &key) {
|
||||||
|
tracing::warn!("instance: store the pipe key: {error}");
|
||||||
|
}
|
||||||
|
key
|
||||||
|
});
|
||||||
|
format!(r"\\.\pipe\io.narl.proton-stream.{key}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server(name: &str, first: bool) -> std::io::Result<NamedPipeServer> {
|
||||||
|
ServerOptions::new()
|
||||||
|
.first_pipe_instance(first)
|
||||||
|
.reject_remote_clients(true)
|
||||||
|
.access_outbound(false)
|
||||||
|
.create(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn claim(runtime: &tokio::runtime::Runtime, dirs: &AppDirs, request: &Request) -> Claim {
|
||||||
|
let name = pipe(dirs);
|
||||||
|
// Registering a pipe with tokio needs its reactor.
|
||||||
|
let _entered = runtime.enter();
|
||||||
|
match server(&name, true) {
|
||||||
|
Ok(first) => Claim::First(Listener {
|
||||||
|
inner: Some(Inner { name, first }),
|
||||||
|
}),
|
||||||
|
// Taken: there is a window. Hand it the request.
|
||||||
|
Err(_) => {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
match std::fs::OpenOptions::new().write(true).open(&name) {
|
||||||
|
Ok(mut pipe) => {
|
||||||
|
if let Err(error) = pipe.write_all(request.encode().as_bytes()) {
|
||||||
|
tracing::warn!("instance: hand over to the open window: {error}");
|
||||||
|
}
|
||||||
|
return Claim::Handed;
|
||||||
|
}
|
||||||
|
// Every instance busy with another launch: wait a turn.
|
||||||
|
Err(_) if started.elapsed() < HAND_TIMEOUT => {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("instance: reach the open window: {error}");
|
||||||
|
return Claim::First(Listener { inner: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(inner: Inner, runtime: &tokio::runtime::Runtime, deliver: Deliver) {
|
||||||
|
runtime.spawn(async move {
|
||||||
|
let Inner { name, mut first } = inner;
|
||||||
|
loop {
|
||||||
|
if first.connect().await.is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let connected = first;
|
||||||
|
// The next instance is up before this one is read, so a launch
|
||||||
|
// right behind this one finds a pipe to connect to.
|
||||||
|
first = match server(&name, false) {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!("instance: reopen the pipe: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut limited = connected.take(MOST);
|
||||||
|
let read = limited.read_to_string(&mut text);
|
||||||
|
if let Ok(Ok(_)) = tokio::time::timeout(HAND_TIMEOUT, read).await {
|
||||||
|
deliver.line(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
mod platform {
|
||||||
|
use super::{AppDirs, Claim, Deliver, Listener, Request};
|
||||||
|
|
||||||
|
pub struct Inner;
|
||||||
|
|
||||||
|
pub fn claim(_: &tokio::runtime::Runtime, _: &AppDirs, _: &Request) -> Claim {
|
||||||
|
Claim::First(Listener { inner: None })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(_: Inner, _: &tokio::runtime::Runtime, _: Deliver) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#fragmentsecret";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_launch_with_a_share_link_asks_for_it_to_be_added() {
|
||||||
|
let args = ["--verbose".to_owned(), format!(" {LINK} ")];
|
||||||
|
assert_eq!(Request::from_args(args), Request::AddShare(LINK.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_launch_with_no_share_link_only_raises_the_window() {
|
||||||
|
let args = ["https://example.com/".to_owned()];
|
||||||
|
assert_eq!(Request::from_args(args), Request::Raise);
|
||||||
|
assert_eq!(Request::from_args(Vec::new()), Request::Raise);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_survives_the_wire() {
|
||||||
|
for request in [Request::Raise, Request::AddShare(LINK.to_owned())] {
|
||||||
|
assert_eq!(Request::decode(&request.encode()), Some(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_that_is_not_a_request_is_ignored() {
|
||||||
|
assert_eq!(Request::decode("add https://example.com/"), None);
|
||||||
|
assert_eq!(Request::decode("open the pod bay doors"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,15 @@
|
|||||||
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
|
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod desktop_prefs;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod inhibit;
|
||||||
|
pub mod instance;
|
||||||
|
pub mod media;
|
||||||
|
pub mod notify;
|
||||||
pub mod pacing;
|
pub mod pacing;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
pub mod window;
|
||||||
@@ -31,10 +31,30 @@ fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
let dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||||
|
|
||||||
|
// Before the window: a second launch hands its request to the first and
|
||||||
|
// never opens one.
|
||||||
|
let request = pstr_app::instance::Request::from_args(std::env::args().skip(1));
|
||||||
|
let instance = match pstr_app::instance::claim(&runtime, &dirs, &request) {
|
||||||
|
pstr_app::instance::Claim::First(listener) => listener,
|
||||||
|
pstr_app::instance::Claim::Handed => {
|
||||||
|
tracing::info!("handed over to the window already open");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let window = pstr_app::window::WindowState::load(&dirs.window_file());
|
||||||
|
let mut viewport = egui::ViewportBuilder::default();
|
||||||
|
// X11 and Windows take the icon from the window. Wayland ignores this and
|
||||||
|
// looks the app id up in the installed desktop entry instead.
|
||||||
|
match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) {
|
||||||
|
Ok(icon) => viewport = viewport.with_icon(icon),
|
||||||
|
Err(error) => tracing::debug!("window icon: {error}"),
|
||||||
|
}
|
||||||
let mut options = eframe::NativeOptions {
|
let mut options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: viewport
|
||||||
.with_title("proton-stream")
|
.with_title("proton-stream")
|
||||||
.with_inner_size([1180.0, 780.0])
|
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
|
||||||
|
.with_maximized(window.is_some_and(|state| state.maximized))
|
||||||
.with_min_inner_size([720.0, 480.0])
|
.with_min_inner_size([720.0, 480.0])
|
||||||
.with_app_id("io.narl.proton-stream"),
|
.with_app_id("io.narl.proton-stream"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -48,7 +68,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
"proton-stream",
|
"proton-stream",
|
||||||
options,
|
options,
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
pstr_app::app::App::new(cc, runtime, dirs)
|
pstr_app::app::App::new(cc, runtime, dirs, instance, request)
|
||||||
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
||||||
.map_err(|error| error.into())
|
.map_err(|error| error.into())
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! The desktop's view of what is playing: media keys, the media widget in the
|
||||||
|
//! panel, the lock screen.
|
||||||
|
//!
|
||||||
|
//! MPRIS on Linux, the System Media Transport Controls on Windows, through
|
||||||
|
//! `souvlaki`. Both are optional in the strongest sense — a session with no
|
||||||
|
//! D-Bus, or a desktop with nothing listening, plays exactly as before — so
|
||||||
|
//! every failure here is a debug line and nothing more.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{Receiver, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use souvlaki::{
|
||||||
|
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
|
||||||
|
SeekDirection,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{Action, Adjacent};
|
||||||
|
use crate::playback::{Command, Playback};
|
||||||
|
|
||||||
|
/// How far one press of a seek key moves, when the desktop does not say.
|
||||||
|
const SEEK_SECONDS: f64 = 10.0;
|
||||||
|
|
||||||
|
/// What was last told to the desktop, so it is told again only on a change.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct Told {
|
||||||
|
/// What the metadata was built from: the player, whether its duration was
|
||||||
|
/// known yet, and the cover. Any of them changing means sending it again.
|
||||||
|
item: Option<(u64, bool, Option<String>)>,
|
||||||
|
paused: bool,
|
||||||
|
/// Where the playhead was, to the second. A desktop interpolates between
|
||||||
|
/// updates, so this only has to be sent when the playhead jumps.
|
||||||
|
second: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MediaSession {
|
||||||
|
controls: Option<MediaControls>,
|
||||||
|
events: Receiver<MediaControlEvent>,
|
||||||
|
told: Option<Told>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSession {
|
||||||
|
/// Register with the desktop. `window` is the handle SMTC needs on
|
||||||
|
/// Windows; elsewhere it is not used.
|
||||||
|
pub fn new(ctx: &egui::Context, window: Option<*mut std::ffi::c_void>) -> Self {
|
||||||
|
let (sender, events) = channel();
|
||||||
|
let config = PlatformConfig {
|
||||||
|
display_name: "proton-stream",
|
||||||
|
dbus_name: "proton_stream",
|
||||||
|
hwnd: window,
|
||||||
|
};
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let controls = MediaControls::new(config)
|
||||||
|
.and_then(|mut controls| {
|
||||||
|
controls.attach(move |event| {
|
||||||
|
// The press arrives on the D-Bus thread; it is acted on in
|
||||||
|
// the next frame, which this is what asks for.
|
||||||
|
let _ = sender.send(event);
|
||||||
|
ctx.request_repaint();
|
||||||
|
})?;
|
||||||
|
Ok(controls)
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("media controls unavailable: {error:?}"))
|
||||||
|
.ok();
|
||||||
|
Self {
|
||||||
|
controls,
|
||||||
|
events,
|
||||||
|
told: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the presses since the last frame into actions.
|
||||||
|
pub fn drain(&mut self, playback: Option<&Playback>, actions: &mut Vec<Action>) {
|
||||||
|
while let Ok(event) = self.events.try_recv() {
|
||||||
|
if let Some(action) = action_for(event, playback) {
|
||||||
|
actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the desktop what is playing, if that changed since the last frame.
|
||||||
|
///
|
||||||
|
/// `cover` is a `file://` URL to the title's artwork when it is on disk.
|
||||||
|
pub fn show(&mut self, playback: Option<&Playback>, cover: Option<&str>) {
|
||||||
|
let Some(controls) = self.controls.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let told = Told {
|
||||||
|
item: playback.map(|playback| {
|
||||||
|
(
|
||||||
|
playback.id,
|
||||||
|
playback.duration.is_some(),
|
||||||
|
cover.map(str::to_owned),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
paused: playback.is_none_or(|playback| playback.paused),
|
||||||
|
second: playback.map_or(0, |playback| playback.position as i64),
|
||||||
|
};
|
||||||
|
let previous = self.told.replace(told.clone());
|
||||||
|
// Within the second, or one second on from it: playback carrying on,
|
||||||
|
// which the desktop already shows without being told.
|
||||||
|
if let Some(previous) = &previous
|
||||||
|
&& previous.item == told.item
|
||||||
|
&& previous.paused == told.paused
|
||||||
|
&& (0..=1).contains(&(told.second - previous.second))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(playback) = playback else {
|
||||||
|
if let Err(error) = controls.set_playback(MediaPlayback::Stopped) {
|
||||||
|
tracing::debug!("media controls: {error:?}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if previous.is_none_or(|previous| previous.item != told.item) {
|
||||||
|
let target = &playback.target;
|
||||||
|
let episode = target.caption();
|
||||||
|
let metadata = MediaMetadata {
|
||||||
|
title: Some(if episode.is_empty() {
|
||||||
|
&target.title_name
|
||||||
|
} else {
|
||||||
|
&episode
|
||||||
|
}),
|
||||||
|
album: Some(&target.title_name),
|
||||||
|
artist: None,
|
||||||
|
cover_url: cover,
|
||||||
|
duration: playback.duration.map(Duration::from_secs_f64),
|
||||||
|
};
|
||||||
|
if let Err(error) = controls.set_metadata(metadata) {
|
||||||
|
tracing::debug!("media controls: {error:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let progress = Some(MediaPosition(Duration::from_secs_f64(
|
||||||
|
playback.position.max(0.0),
|
||||||
|
)));
|
||||||
|
let state = if playback.paused {
|
||||||
|
MediaPlayback::Paused { progress }
|
||||||
|
} else {
|
||||||
|
MediaPlayback::Playing { progress }
|
||||||
|
};
|
||||||
|
if let Err(error) = controls.set_playback(state) {
|
||||||
|
tracing::debug!("media controls: {error:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a press means here. `None` for the ones with nothing to act on.
|
||||||
|
fn action_for(event: MediaControlEvent, playback: Option<&Playback>) -> Option<Action> {
|
||||||
|
let playback = playback?;
|
||||||
|
let seek = |direction: SeekDirection, seconds: f64| {
|
||||||
|
Action::Player(Command::SeekBy(match direction {
|
||||||
|
SeekDirection::Forward => seconds,
|
||||||
|
SeekDirection::Backward => -seconds,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
Some(match event {
|
||||||
|
MediaControlEvent::Toggle => Action::Player(Command::TogglePause),
|
||||||
|
MediaControlEvent::Play if playback.paused => Action::Player(Command::TogglePause),
|
||||||
|
MediaControlEvent::Pause if !playback.paused => Action::Player(Command::TogglePause),
|
||||||
|
MediaControlEvent::Next => Action::PlayAdjacent(Adjacent::Next),
|
||||||
|
MediaControlEvent::Previous => Action::PlayAdjacent(Adjacent::Previous),
|
||||||
|
MediaControlEvent::Stop => Action::LeavePlayer,
|
||||||
|
MediaControlEvent::Seek(direction) => seek(direction, SEEK_SECONDS),
|
||||||
|
MediaControlEvent::SeekBy(direction, by) => seek(direction, by.as_secs_f64()),
|
||||||
|
MediaControlEvent::SetPosition(MediaPosition(at)) => {
|
||||||
|
Action::Player(Command::SeekTo(at.as_secs_f64()))
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//! Desktop notifications, for things that finish while nobody is looking.
|
||||||
|
//!
|
||||||
|
//! Only when the window does not have focus: a viewer who is looking at the
|
||||||
|
//! app already has the toast, and a second copy of it from the panel is noise.
|
||||||
|
//! That rule is the caller's — see `App::notify`.
|
||||||
|
//!
|
||||||
|
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
|
||||||
|
//! macOS yet.
|
||||||
|
//!
|
||||||
|
//! A Windows toast is shown under an application id, and Windows drops a toast
|
||||||
|
//! whose id it does not know without a word. The id is registered for the
|
||||||
|
//! current user under `HKCU\Software\Classes\AppUserModelId` — the same
|
||||||
|
//! registration the Windows App SDK makes for an app with no package — which
|
||||||
|
//! works for the MSI and the portable zip alike. If that write fails, the
|
||||||
|
//! toast goes out under PowerShell's id instead, which Windows always knows: a
|
||||||
|
//! notification that names the wrong program beats one that never appears.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Sends notifications from a thread of its own, so a notification daemon
|
||||||
|
/// that is slow to answer never costs a frame.
|
||||||
|
pub struct Notifier {
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notifier {
|
||||||
|
/// `cache` is where Windows is pointed for the toast's icon.
|
||||||
|
pub fn new(cache: &Path) -> Self {
|
||||||
|
let _ = cache;
|
||||||
|
Self {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
orders: linux::spawn(),
|
||||||
|
#[cfg(windows)]
|
||||||
|
orders: windows::spawn(cache.join("notification-icon.png")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
if let Some(orders) = &self.orders {
|
||||||
|
let _ = orders.send((summary.into(), body.into()));
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
let _ = (summary.into(), body.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux {
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use dbus::arg::PropMap;
|
||||||
|
use dbus::blocking::Connection;
|
||||||
|
|
||||||
|
pub fn spawn() -> Option<Sender<(String, String)>> {
|
||||||
|
let (orders, receiver) = channel::<(String, String)>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("notify".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Connected on first use rather than at startup: most sessions
|
||||||
|
// never send one.
|
||||||
|
let mut connection: Option<Connection> = None;
|
||||||
|
for (summary, body) in receiver {
|
||||||
|
if connection.is_none() {
|
||||||
|
connection = Connection::new_session()
|
||||||
|
.inspect_err(|error| tracing::debug!("notifications: {error}"))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let Some(connection) = &connection else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let proxy = connection.with_proxy(
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
"/org/freedesktop/Notifications",
|
||||||
|
Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
let answer: Result<(u32,), _> = proxy.method_call(
|
||||||
|
"org.freedesktop.Notifications",
|
||||||
|
"Notify",
|
||||||
|
(
|
||||||
|
"proton-stream",
|
||||||
|
0u32,
|
||||||
|
"io.narl.proton-stream",
|
||||||
|
summary,
|
||||||
|
body,
|
||||||
|
Vec::<String>::new(),
|
||||||
|
PropMap::new(),
|
||||||
|
-1i32,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if let Err(error) = answer {
|
||||||
|
tracing::debug!("notification: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{Sender, channel};
|
||||||
|
|
||||||
|
use tauri_winrt_notification::Toast;
|
||||||
|
|
||||||
|
/// The id toasts are shown under, and the key it is registered at.
|
||||||
|
const APP_ID: &str = "io.narl.proton-stream";
|
||||||
|
const ICON: &[u8] = include_bytes!("../assets/icon-256.png");
|
||||||
|
|
||||||
|
pub fn spawn(icon: PathBuf) -> Option<Sender<(String, String)>> {
|
||||||
|
let (orders, receiver) = channel::<(String, String)>();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("notify".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Registered on first use rather than at startup: most
|
||||||
|
// sessions never send one.
|
||||||
|
let mut app_id: Option<&str> = None;
|
||||||
|
for (summary, body) in receiver {
|
||||||
|
let id = *app_id.get_or_insert_with(|| {
|
||||||
|
let _ = std::fs::write(&icon, ICON);
|
||||||
|
if register(&icon) {
|
||||||
|
APP_ID
|
||||||
|
} else {
|
||||||
|
Toast::POWERSHELL_APP_ID
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let shown = Toast::new(id).title(&summary).text1(&body).show();
|
||||||
|
if let Err(error) = shown {
|
||||||
|
tracing::debug!("notification: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
|
||||||
|
.ok()?;
|
||||||
|
Some(orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell Windows the app id's display name and icon. Per user, no rights
|
||||||
|
/// needed, and written again every session so a moved install points at
|
||||||
|
/// a live icon.
|
||||||
|
fn register(icon: &Path) -> bool {
|
||||||
|
use windows_sys::Win32::System::Registry::{
|
||||||
|
HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey,
|
||||||
|
RegCreateKeyExW, RegSetValueExW,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn wide(text: &str) -> Vec<u16> {
|
||||||
|
text.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
|
||||||
|
let mut key: HKEY = std::ptr::null_mut();
|
||||||
|
// SAFETY: every pointer is to a live, NUL-terminated buffer or an out
|
||||||
|
// parameter on this stack frame, and the key is closed before return.
|
||||||
|
unsafe {
|
||||||
|
let created = RegCreateKeyExW(
|
||||||
|
HKEY_CURRENT_USER,
|
||||||
|
path.as_ptr(),
|
||||||
|
0,
|
||||||
|
std::ptr::null(),
|
||||||
|
REG_OPTION_NON_VOLATILE,
|
||||||
|
KEY_WRITE,
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut key,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
);
|
||||||
|
if created != 0 {
|
||||||
|
tracing::debug!("register the notification id: error {created}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let icon = icon.to_string_lossy();
|
||||||
|
let mut written = true;
|
||||||
|
for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] {
|
||||||
|
let name = wide(name);
|
||||||
|
let value = wide(value);
|
||||||
|
let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX);
|
||||||
|
let set =
|
||||||
|
RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes);
|
||||||
|
written &= set == 0;
|
||||||
|
}
|
||||||
|
RegCloseKey(key);
|
||||||
|
written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,16 @@ impl PlaybackTarget {
|
|||||||
|
|
||||||
/// The line under the title while this plays: the numbering, and the
|
/// The line under the title while this plays: the numbering, and the
|
||||||
/// episode's name when a provider has given one.
|
/// episode's name when a provider has given one.
|
||||||
|
/// How to name this file to someone not looking at its title page:
|
||||||
|
/// the title and the episode, or the file name when there is no title.
|
||||||
|
pub fn caption_or_name(&self) -> String {
|
||||||
|
match (self.title_name.is_empty(), self.caption()) {
|
||||||
|
(false, caption) if !caption.is_empty() => format!("{} — {caption}", self.title_name),
|
||||||
|
(false, _) => self.title_name.clone(),
|
||||||
|
(true, _) => self.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn caption(&self) -> String {
|
pub fn caption(&self) -> String {
|
||||||
match &self.episode_name {
|
match &self.episode_name {
|
||||||
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
|
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
|
||||||
@@ -120,7 +130,7 @@ impl PlaybackTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What the UI can ask the player to do.
|
/// What the UI can ask the player to do.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
TogglePause,
|
TogglePause,
|
||||||
SeekBy(f64),
|
SeekBy(f64),
|
||||||
@@ -134,6 +144,13 @@ pub enum Command {
|
|||||||
/// every file it loads afterwards, so picking Japanese audio for one
|
/// every file it loads afterwards, so picking Japanese audio for one
|
||||||
/// episode picks it for the next one too.
|
/// episode picks it for the next one too.
|
||||||
SelectTrack(TrackKind, Option<i64>),
|
SelectTrack(TrackKind, Option<i64>),
|
||||||
|
/// Move subtitles, as a percentage of the window's height. See
|
||||||
|
/// [`Playback::lift_subtitles`].
|
||||||
|
SetSubtitlePosition(f64),
|
||||||
|
/// 1.0 is normal speed.
|
||||||
|
SetSpeed(f64),
|
||||||
|
/// Save the current frame to this file.
|
||||||
|
Screenshot(std::path::PathBuf),
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +179,11 @@ pub struct Playback {
|
|||||||
/// 0–100, as mpv has it.
|
/// 0–100, as mpv has it.
|
||||||
pub volume: f64,
|
pub volume: f64,
|
||||||
pub muted: bool,
|
pub muted: bool,
|
||||||
|
/// Playback speed, 1.0 being normal.
|
||||||
|
pub speed: f64,
|
||||||
|
/// Where subtitles were last put, so they are only moved when that changes
|
||||||
|
/// rather than once a frame.
|
||||||
|
subtitle_position: f64,
|
||||||
/// What the file contains. Empty until mpv has demuxed it.
|
/// What the file contains. Empty until mpv has demuxed it.
|
||||||
pub tracks: Vec<Track>,
|
pub tracks: Vec<Track>,
|
||||||
/// The file's chapters, in order. Empty for a file muxed without them.
|
/// The file's chapters, in order. Empty for a file muxed without them.
|
||||||
@@ -179,6 +201,10 @@ pub struct Playback {
|
|||||||
/// A seek was issued and the picture has not come back yet — the thing
|
/// A seek was issued and the picture has not come back yet — the thing
|
||||||
/// worth showing a spinner for on a link this slow.
|
/// worth showing a spinner for on a link this slow.
|
||||||
pub seeking: bool,
|
pub seeking: bool,
|
||||||
|
/// The end of what mpv has read ahead, in seconds from the start.
|
||||||
|
pub buffered: Option<f64>,
|
||||||
|
/// Playback is held waiting for data.
|
||||||
|
pub buffering: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Playback {
|
impl Playback {
|
||||||
@@ -196,10 +222,9 @@ impl Playback {
|
|||||||
target: PlaybackTarget,
|
target: PlaybackTarget,
|
||||||
stream: VideoStream,
|
stream: VideoStream,
|
||||||
gl: Option<&Arc<glow::Context>>,
|
gl: Option<&Arc<glow::Context>>,
|
||||||
ctx: &egui::Context,
|
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let prefs = engine.playback_prefs();
|
let prefs = engine.playback_prefs();
|
||||||
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?;
|
let (player, video) = build_player(engine, &target, &prefs, gl)?;
|
||||||
|
|
||||||
let (commands, orders) = channel::<Command>();
|
let (commands, orders) = channel::<Command>();
|
||||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -234,6 +259,8 @@ impl Playback {
|
|||||||
paused: false,
|
paused: false,
|
||||||
volume: prefs.volume,
|
volume: prefs.volume,
|
||||||
muted: prefs.muted,
|
muted: prefs.muted,
|
||||||
|
subtitle_position: 100.0,
|
||||||
|
speed: prefs.speed,
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
roles: Vec::new(),
|
roles: Vec::new(),
|
||||||
@@ -242,10 +269,30 @@ impl Playback {
|
|||||||
duration: None,
|
duration: None,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
seeking: false,
|
seeking: false,
|
||||||
|
buffered: None,
|
||||||
|
buffering: false,
|
||||||
target,
|
target,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep subtitles clear of `covered` points at the bottom of a picture
|
||||||
|
/// `height` points tall — the controls, while they are up.
|
||||||
|
///
|
||||||
|
/// Without it the line being read sits under the seek bar for as long as
|
||||||
|
/// the mouse is moving, which is exactly when someone is looking at the
|
||||||
|
/// screen.
|
||||||
|
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
|
||||||
|
let position = if covered > 0.0 && height > 0.0 {
|
||||||
|
(100.0 * (1.0 - f64::from(covered / height))).round()
|
||||||
|
} else {
|
||||||
|
100.0
|
||||||
|
};
|
||||||
|
if position != self.subtitle_position {
|
||||||
|
self.subtitle_position = position;
|
||||||
|
self.send(Command::SetSubtitlePosition(position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the picture is drawn in this window rather than mpv's own.
|
/// Whether the picture is drawn in this window rather than mpv's own.
|
||||||
pub fn is_embedded(&self) -> bool {
|
pub fn is_embedded(&self) -> bool {
|
||||||
self.video.is_some()
|
self.video.is_some()
|
||||||
@@ -329,6 +376,8 @@ impl Playback {
|
|||||||
PlayerEvent::Paused(paused) => self.paused = *paused,
|
PlayerEvent::Paused(paused) => self.paused = *paused,
|
||||||
PlayerEvent::Seek => self.seeking = true,
|
PlayerEvent::Seek => self.seeking = true,
|
||||||
PlayerEvent::PlaybackRestart => self.seeking = false,
|
PlayerEvent::PlaybackRestart => self.seeking = false,
|
||||||
|
PlayerEvent::Buffered(end) => self.buffered = Some(*end),
|
||||||
|
PlayerEvent::Buffering(held) => self.buffering = *held,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +414,6 @@ fn build_player(
|
|||||||
target: &PlaybackTarget,
|
target: &PlaybackTarget,
|
||||||
prefs: &PlaybackPrefs,
|
prefs: &PlaybackPrefs,
|
||||||
gl: Option<&Arc<glow::Context>>,
|
gl: Option<&Arc<glow::Context>>,
|
||||||
ctx: &egui::Context,
|
|
||||||
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
|
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
|
||||||
let title = format!("proton-stream — {}", target.name);
|
let title = format!("proton-stream — {}", target.name);
|
||||||
|
|
||||||
@@ -377,6 +425,7 @@ fn build_player(
|
|||||||
// controller would be a second set of controls over the same file.
|
// controller would be a second set of controls over the same file.
|
||||||
on_screen_controller: false,
|
on_screen_controller: false,
|
||||||
default_keybindings: false,
|
default_keybindings: false,
|
||||||
|
hardware_decoding: engine.desktop_prefs().hardware_decoding,
|
||||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -386,8 +435,12 @@ fn build_player(
|
|||||||
// SAFETY: called from `App::ui`, where eframe has made this
|
// SAFETY: called from `App::ui`, where eframe has made this
|
||||||
// context current, and the surface is dropped from the same
|
// context current, and the surface is dropped from the same
|
||||||
// place — see `video.rs`.
|
// place — see `video.rs`.
|
||||||
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) }
|
match unsafe {
|
||||||
{
|
VideoSurface::new(Arc::clone(&player), Arc::clone(gl), {
|
||||||
|
let engine = engine.clone();
|
||||||
|
move || engine.wake()
|
||||||
|
})
|
||||||
|
} {
|
||||||
Ok(video) => return Ok((player, Some(video))),
|
Ok(video) => return Ok((player, Some(video))),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!("embed the video: {error}; falling back to a window");
|
tracing::warn!("embed the video: {error}; falling back to a window");
|
||||||
@@ -406,6 +459,7 @@ fn build_player(
|
|||||||
|
|
||||||
let config = PlayerConfig {
|
let config = PlayerConfig {
|
||||||
window_title: title,
|
window_title: title,
|
||||||
|
hardware_decoding: engine.desktop_prefs().hardware_decoding,
|
||||||
..from_prefs(prefs, target.track_prefs.as_deref())
|
..from_prefs(prefs, target.track_prefs.as_deref())
|
||||||
};
|
};
|
||||||
let player = Player::new(engine.runtime().clone(), config)
|
let player = Player::new(engine.runtime().clone(), config)
|
||||||
@@ -428,6 +482,9 @@ fn from_prefs(
|
|||||||
.and_then(|p| p.subtitle_language.clone())
|
.and_then(|p| p.subtitle_language.clone())
|
||||||
.or_else(|| prefs.subtitle_language.clone()),
|
.or_else(|| prefs.subtitle_language.clone()),
|
||||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||||
|
// Carried from file to file, like the volume: somebody who watches at
|
||||||
|
// 1.25 watches the next episode at 1.25 too.
|
||||||
|
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
||||||
..PlayerConfig::default()
|
..PlayerConfig::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,8 +544,12 @@ fn run(
|
|||||||
PlayerEvent::FileLoaded => {
|
PlayerEvent::FileLoaded => {
|
||||||
duration = player.duration();
|
duration = player.duration();
|
||||||
// The first moment there is a track list to read: mpv has
|
// The first moment there is a track list to read: mpv has
|
||||||
// demuxed the file and made its own default selection, which
|
// demuxed the file and made its own default selection. Put the
|
||||||
// is what the menus have to open showing.
|
// title's own choice over it before the menus are told, so they
|
||||||
|
// open showing what is actually playing.
|
||||||
|
if let Some(show) = target.track_prefs.as_deref() {
|
||||||
|
restore_tracks(&player, show);
|
||||||
|
}
|
||||||
emit_tracks(&engine, id, &player);
|
emit_tracks(&engine, id, &player);
|
||||||
// Only now is there a timeline to seek within.
|
// Only now is there a timeline to seek within.
|
||||||
if !resumed && let Some(at) = target.resume_at {
|
if !resumed && let Some(at) = target.resume_at {
|
||||||
@@ -565,6 +626,9 @@ fn apply_command(
|
|||||||
Command::SeekTo(seconds) => player.seek_to(seconds),
|
Command::SeekTo(seconds) => player.seek_to(seconds),
|
||||||
Command::SetVolume(volume) => player.set_volume(volume),
|
Command::SetVolume(volume) => player.set_volume(volume),
|
||||||
Command::SetMuted(muted) => player.set_muted(muted),
|
Command::SetMuted(muted) => player.set_muted(muted),
|
||||||
|
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
||||||
|
Command::SetSpeed(speed) => player.set_speed(speed),
|
||||||
|
Command::Screenshot(path) => player.screenshot(&path),
|
||||||
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
||||||
Command::Stop => {
|
Command::Stop => {
|
||||||
let _ = player.quit();
|
let _ = player.quit();
|
||||||
@@ -611,6 +675,44 @@ fn select_track(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put back the tracks this title was last watched with.
|
||||||
|
///
|
||||||
|
/// `alang`/`slang` already steered mpv's pick by language. This is for what a
|
||||||
|
/// language cannot say — which of two English subtitle tracks, or an audio track
|
||||||
|
/// with a name and no tag — so only a remembered *title* acts here; a title with
|
||||||
|
/// nothing but a language keeps mpv's selection, which honours it already.
|
||||||
|
fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) {
|
||||||
|
let tracks = player.tracks();
|
||||||
|
let wanted = [
|
||||||
|
(
|
||||||
|
TrackKind::Audio,
|
||||||
|
show.audio_language.as_deref(),
|
||||||
|
show.audio_title.as_deref(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
TrackKind::Subtitle,
|
||||||
|
show.subtitle_language.as_deref(),
|
||||||
|
show.subtitle_title.as_deref(),
|
||||||
|
show.subtitles,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (kind, language, title, enabled) in wanted {
|
||||||
|
if !enabled || title.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let playing = tracks
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected);
|
||||||
|
if !playing && let Err(error) = player.select_track(kind, Some(track)) {
|
||||||
|
tracing::warn!("restore {} track {track}: {error}", kind.label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the UI what the file contains and what of it is playing.
|
/// Tell the UI what the file contains and what of it is playing.
|
||||||
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
|
||||||
engine.emit(Event::Player {
|
engine.emit(Event::Player {
|
||||||
|
|||||||
@@ -84,38 +84,171 @@ impl Role {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Headings are set in the semibold cut; everything below them in the
|
||||||
|
/// regular one.
|
||||||
|
pub fn family(self) -> egui::FontFamily {
|
||||||
|
match self {
|
||||||
|
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
|
||||||
|
_ => egui::FontFamily::Proportional,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// For the hand-painted parts, which lay text out through a `Painter` and
|
/// For the hand-painted parts, which lay text out through a `Painter` and
|
||||||
/// never see a `RichText`.
|
/// never see a `RichText`.
|
||||||
pub fn font(self) -> egui::FontId {
|
pub fn font(self) -> egui::FontId {
|
||||||
egui::FontId::proportional(self.size())
|
egui::FontId::new(self.size(), self.family())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For the parts that go through egui's widgets.
|
/// For the parts that go through egui's widgets.
|
||||||
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
||||||
egui::RichText::new(text).size(self.size())
|
egui::RichText::new(text)
|
||||||
|
.size(self.size())
|
||||||
|
.family(self.family())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install a real CJK fallback before the default egui fonts. egui ships a
|
/// The spacing scale. Gaps between things come from here rather than being
|
||||||
/// compact Latin font, but it intentionally does not bundle the multi-megabyte
|
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
|
||||||
/// CJK families. Native desktops already provide one, so use it when present.
|
/// and never 14 in one place and 15 in the next.
|
||||||
pub fn install_font_fallbacks(ctx: &egui::Context) {
|
pub mod space {
|
||||||
|
/// Between lines that belong to one thing: a title and its caption.
|
||||||
|
pub const XXS: f32 = 2.0;
|
||||||
|
pub const XS: f32 = 4.0;
|
||||||
|
/// Between controls in one cluster.
|
||||||
|
pub const S: f32 = 6.0;
|
||||||
|
pub const M: f32 = 8.0;
|
||||||
|
/// Between clusters on one row, and between rows of a form.
|
||||||
|
pub const L: f32 = 12.0;
|
||||||
|
/// Between the groups on a page.
|
||||||
|
pub const XL: f32 = 16.0;
|
||||||
|
/// Between sections.
|
||||||
|
pub const XXL: f32 = 24.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corner radii, as `egui::CornerRadius::same` takes them.
|
||||||
|
pub mod radius {
|
||||||
|
/// A progress bar, or a strip along an edge.
|
||||||
|
pub const BAR: u8 = 3;
|
||||||
|
/// A badge over artwork. Android's `Shape.kt` has the same number.
|
||||||
|
pub const BADGE: u8 = 4;
|
||||||
|
/// A small control inside something else: a menu entry, a chip.
|
||||||
|
pub const SM: u8 = 6;
|
||||||
|
/// Everything else either client paints: cards, buttons, the tab pill.
|
||||||
|
pub const MD: u8 = 8;
|
||||||
|
/// Something that floats: a row card, a dialog, a toast, the hero.
|
||||||
|
pub const LG: u8 = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long things take to change, in seconds, for `Context::animate_*`.
|
||||||
|
pub mod motion {
|
||||||
|
/// A press, which has to answer before the finger is off the button.
|
||||||
|
pub const PRESS: f32 = 0.05;
|
||||||
|
/// A hover arriving and leaving.
|
||||||
|
pub const HOVER: f32 = 0.12;
|
||||||
|
/// A control changing state: a switch flipping, the tab pill travelling,
|
||||||
|
/// one page giving way to the next.
|
||||||
|
pub const STATE: f32 = 0.15;
|
||||||
|
/// Chrome fading in and out: the player's controls, a toast.
|
||||||
|
pub const FADE: f32 = 0.18;
|
||||||
|
/// Artwork arriving over its placeholder. Slower, because it replaces
|
||||||
|
/// something rather than adding to it, and a fast swap reads as a flicker.
|
||||||
|
pub const ARRIVE: f32 = 0.24;
|
||||||
|
|
||||||
|
/// Fast out, slow in: the curve every hand-driven transition uses.
|
||||||
|
pub fn ease_out(t: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UI typeface, bundled rather than taken from the system so a title
|
||||||
|
/// sets the same on every desktop. Inter, under the OFL — the licence is
|
||||||
|
/// beside the files.
|
||||||
|
///
|
||||||
|
/// Both files have their private use area mappings removed, because the
|
||||||
|
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
||||||
|
/// own alternate glyphs, and as the first font in the family it won: the
|
||||||
|
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
||||||
|
/// in the files; only the character map entries are gone. Strip them again
|
||||||
|
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
|
||||||
|
/// U+E000..=U+F8FF).
|
||||||
|
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
|
||||||
|
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf");
|
||||||
|
|
||||||
|
/// The family the headings are set in. egui has no notion of weight, so the
|
||||||
|
/// semibold cut is a family of its own.
|
||||||
|
pub fn semibold() -> egui::FontFamily {
|
||||||
|
egui::FontFamily::Name("semibold".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inter, the icon font, and egui's own fonts behind them for the emoji and
|
||||||
|
/// symbols Inter does not carry. `cjk` goes last, when there is one.
|
||||||
|
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||||
|
use egui::FontFamily::{Monospace, Proportional};
|
||||||
|
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert("inter".into(), Arc::new(egui::FontData::from_static(INTER)));
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"inter-semibold".into(),
|
||||||
|
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
|
||||||
|
);
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "inter".into());
|
||||||
|
// Right behind Inter. The icons are in the private use area, which the
|
||||||
|
// bundled Inter no longer maps, so neither font shadows the other.
|
||||||
|
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||||
|
if let Some(cjk) = cjk {
|
||||||
|
let name = "system-cjk-fallback".to_owned();
|
||||||
|
fonts.font_data.insert(name.clone(), Arc::new(cjk));
|
||||||
|
for family in [Proportional, Monospace] {
|
||||||
|
fonts.families.entry(family).or_default().push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fallbacks = fonts.families[&Proportional]
|
||||||
|
.iter()
|
||||||
|
.filter(|name| *name != "inter")
|
||||||
|
.cloned();
|
||||||
|
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
||||||
|
.chain(fallbacks)
|
||||||
|
.collect();
|
||||||
|
fonts.families.insert(semibold(), semibold_chain);
|
||||||
|
fonts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the bundled fonts now, and a CJK fallback when the system has one.
|
||||||
|
///
|
||||||
|
/// The bundled ones are compiled in and cost nothing to hand over. The CJK
|
||||||
|
/// fallback is on a thread of its own: `fc-match` and reading a font file of
|
||||||
|
/// tens of megabytes held the first frame back by a visible moment, and every
|
||||||
|
/// name that needs the fallback is still loading from the catalog by then
|
||||||
|
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
||||||
|
/// and native desktops already provide one.
|
||||||
|
pub fn install_fonts(ctx: &egui::Context) {
|
||||||
|
ctx.set_fonts(font_definitions(None));
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("fonts".into())
|
||||||
|
.spawn(move || install_cjk_fallback(&ctx));
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
tracing::debug!("font fallback thread: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_cjk_fallback(ctx: &egui::Context) {
|
||||||
let Some((path, index)) = system_cjk_font() else {
|
let Some((path, index)) = system_cjk_font() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(bytes) = std::fs::read(path) else {
|
let Ok(bytes) = std::fs::read(path) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
let mut data = egui::FontData::from_owned(bytes);
|
let mut data = egui::FontData::from_owned(bytes);
|
||||||
data.index = index;
|
data.index = index;
|
||||||
let name = "system-cjk-fallback".to_owned();
|
ctx.set_fonts(font_definitions(Some(data)));
|
||||||
fonts.font_data.insert(name.clone(), Arc::new(data));
|
ctx.request_repaint();
|
||||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
|
||||||
fonts.families.entry(family).or_default().push(name.clone());
|
|
||||||
}
|
|
||||||
ctx.set_fonts(fonts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -358,6 +491,36 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett
|
|||||||
painter.add(textured(rect, corner_radius, &texture));
|
painter.add(textured(rect, corner_radius, &texture));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rounded rectangle running from one colour to another, at `opacity`.
|
||||||
|
///
|
||||||
|
/// For colours that are not the palette's — a title's own, on a tile with no
|
||||||
|
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
|
||||||
|
pub fn ramp_fill(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: Rect,
|
||||||
|
corner_radius: CornerRadius,
|
||||||
|
(from, to): (Color32, Color32),
|
||||||
|
direction: Direction,
|
||||||
|
opacity: f32,
|
||||||
|
) {
|
||||||
|
let tint = Color32::WHITE.gamma_multiply(opacity);
|
||||||
|
if !ramps_on() {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
corner_radius,
|
||||||
|
mix(from, to, 0.5).gamma_multiply(opacity),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let texture = ramp(painter.ctx(), from, to, direction);
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
|
||||||
|
texture.id(),
|
||||||
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A ramp from `color` to nothing, for laying artwork into the page.
|
/// A ramp from `color` to nothing, for laying artwork into the page.
|
||||||
///
|
///
|
||||||
/// The other ramps here run between two opaque colours; this one runs down its
|
/// The other ramps here run between two opaque colours; this one runs down its
|
||||||
@@ -524,7 +687,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
|||||||
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
visuals.selection.stroke = Stroke::new(1.0, palette.text);
|
||||||
visuals.hyperlink_color = palette.accent;
|
visuals.hyperlink_color = palette.accent;
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(radius::MD);
|
||||||
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
||||||
visuals.widgets.noninteractive.corner_radius = radius;
|
visuals.widgets.noninteractive.corner_radius = radius;
|
||||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
||||||
@@ -686,6 +849,27 @@ const RAMP_STOPS: usize = 64;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
||||||
|
use skrifa::MetadataProvider as _;
|
||||||
|
|
||||||
|
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
||||||
|
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
||||||
|
let shadowed: Vec<u32> = font
|
||||||
|
.charmap()
|
||||||
|
.mappings()
|
||||||
|
.map(|(codepoint, _)| codepoint)
|
||||||
|
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
shadowed.is_empty(),
|
||||||
|
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
|
||||||
|
shadowed.len(),
|
||||||
|
shadowed[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
fn the_shipped_default_is_the_palette_that_was_here_before() {
|
||||||
let resolved = Palette::resolve(Appearance::default());
|
let resolved = Palette::resolve(Appearance::default());
|
||||||
|
|||||||
@@ -5,70 +5,120 @@ use crate::engine::{DownloadItem, DownloadState};
|
|||||||
use crate::{theme, ui};
|
use crate::{theme, ui};
|
||||||
|
|
||||||
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
|
pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Action>) {
|
||||||
ui.heading("Downloads");
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"Pause and Cancel keep complete downloaded blocks. Delete partial discards them.",
|
|
||||||
));
|
|
||||||
ui.add_space(12.0);
|
|
||||||
|
|
||||||
if downloads.is_empty() {
|
if downloads.is_empty() {
|
||||||
ui.label("No offline downloads.");
|
ui::empty_state(
|
||||||
|
ui,
|
||||||
|
"No downloads",
|
||||||
|
"Download an episode, a season or a whole show from its page, and it plays \
|
||||||
|
without a connection.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut start = 0;
|
egui::ScrollArea::vertical()
|
||||||
while start < downloads.len() {
|
.auto_shrink([false, false])
|
||||||
let title_key = &downloads[start].target.title_key;
|
.show(ui, |ui| {
|
||||||
let title = &downloads[start].target.title_name;
|
ui.set_max_width(960.0);
|
||||||
let end = downloads[start..]
|
summary(ui, downloads);
|
||||||
.iter()
|
ui.add_space(theme::space::L);
|
||||||
.position(|item| item.target.title_key != *title_key)
|
|
||||||
.map_or(downloads.len(), |offset| start + offset);
|
|
||||||
let group = &downloads[start..end];
|
|
||||||
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
|
|
||||||
let total: u64 = group.iter().map(|item| item.total).sum();
|
|
||||||
let completed = group
|
|
||||||
.iter()
|
|
||||||
.filter(|item| item.state == DownloadState::Completed)
|
|
||||||
.count();
|
|
||||||
|
|
||||||
ui.label(theme::Role::Section.rich(title).strong());
|
let mut start = 0;
|
||||||
let totals_known = group.iter().all(|item| item.total > 0);
|
while start < downloads.len() {
|
||||||
ui.horizontal(|ui| {
|
let title_key = &downloads[start].target.title_key;
|
||||||
if totals_known {
|
let end = downloads[start..]
|
||||||
ui.add(
|
.iter()
|
||||||
egui::ProgressBar::new(fraction(downloaded, total))
|
.position(|item| item.target.title_key != *title_key)
|
||||||
.desired_width(260.0)
|
.map_or(downloads.len(), |offset| start + offset);
|
||||||
.show_percentage(),
|
group(ui, &downloads[start..end], actions);
|
||||||
);
|
ui.add_space(theme::space::L);
|
||||||
} else {
|
start = end;
|
||||||
ui.add(egui::Spinner::new().size(16.0));
|
|
||||||
ui.label(ui::muted("Calculating size…"));
|
|
||||||
}
|
}
|
||||||
ui.label(ui::muted(format!(
|
|
||||||
"{} / {} · {completed}/{} files",
|
|
||||||
bytes(downloaded),
|
|
||||||
if !totals_known {
|
|
||||||
"calculating…".to_owned()
|
|
||||||
} else {
|
|
||||||
bytes(total)
|
|
||||||
},
|
|
||||||
group.len()
|
|
||||||
)));
|
|
||||||
});
|
});
|
||||||
for item in group {
|
}
|
||||||
row(ui, item, actions);
|
|
||||||
|
/// The line under the heading: how much, how fast, how long.
|
||||||
|
fn summary(ui: &mut egui::Ui, downloads: &[DownloadItem]) {
|
||||||
|
ui::section(ui, "Downloads");
|
||||||
|
let running: Vec<_> = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Running)
|
||||||
|
.collect();
|
||||||
|
let rate: f64 = running.iter().map(|item| item.rate).sum();
|
||||||
|
let left: u64 = downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
matches!(
|
||||||
|
item.state,
|
||||||
|
DownloadState::Queued | DownloadState::Running | DownloadState::Paused
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|item| item.total.saturating_sub(item.downloaded))
|
||||||
|
.sum();
|
||||||
|
let mut parts = vec![format!(
|
||||||
|
"{} available offline",
|
||||||
|
ui::library::plural(
|
||||||
|
downloads
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Completed)
|
||||||
|
.count(),
|
||||||
|
"file"
|
||||||
|
)
|
||||||
|
)];
|
||||||
|
if !running.is_empty() {
|
||||||
|
parts.push(format!("{}/s", size(rate as u64)));
|
||||||
|
}
|
||||||
|
if left > 0 {
|
||||||
|
parts.push(format!("{} to go", size(left)));
|
||||||
|
if rate > 0.0 {
|
||||||
|
parts.push(format!("about {}", ui::format_time(left as f64 / rate)));
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
}
|
||||||
start = end;
|
ui.label(ui::muted(parts.join(" · ")));
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Pausing or cancelling keeps every block already downloaded.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One title's downloads, under a bar for all of them together.
|
||||||
|
fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
|
||||||
|
let title = &group[0].target.title_name;
|
||||||
|
let downloaded: u64 = group.iter().map(|item| item.downloaded).sum();
|
||||||
|
let total: u64 = group.iter().map(|item| item.total).sum();
|
||||||
|
let completed = group
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == DownloadState::Completed)
|
||||||
|
.count();
|
||||||
|
let totals_known = group.iter().all(|item| item.total > 0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Subhead.rich(title).strong());
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
ui.label(ui::muted(if totals_known {
|
||||||
|
format!(
|
||||||
|
"{completed} of {} · {} of {}",
|
||||||
|
group.len(),
|
||||||
|
size(downloaded),
|
||||||
|
size(total)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{completed} of {} · working out the size…", group.len())
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if totals_known && group.len() > 1 {
|
||||||
|
ui::progress_bar(ui, fraction(downloaded, total), ui.available_width());
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
for item in group {
|
||||||
|
row(ui, item, actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
|
||||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -78,85 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
|||||||
item.target.subtitle.as_str()
|
item.target.subtitle.as_str()
|
||||||
};
|
};
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
[180.0, 18.0],
|
[72.0, 18.0],
|
||||||
egui::Label::new(egui::RichText::new(caption).strong()).truncate(),
|
egui::Label::new(egui::RichText::new(caption).monospace()).truncate(),
|
||||||
)
|
)
|
||||||
.on_hover_text(&item.target.name);
|
.on_hover_text(&item.target.name);
|
||||||
|
|
||||||
let status = match &item.state {
|
let (status, colour) = status(item);
|
||||||
DownloadState::Queued => "Queued".to_owned(),
|
|
||||||
DownloadState::Running => "Downloading".to_owned(),
|
|
||||||
DownloadState::Paused => "Paused".to_owned(),
|
|
||||||
DownloadState::Completed => "Available offline".to_owned(),
|
|
||||||
DownloadState::Cancelled => "Cancelled · partial kept".to_owned(),
|
|
||||||
DownloadState::Failed(error) => format!("Failed: {error}"),
|
|
||||||
};
|
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
[240.0, 18.0],
|
[220.0, 18.0],
|
||||||
egui::Label::new(ui::muted(status)).truncate(),
|
egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if item.total > 0 {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.add(
|
buttons(ui, item, actions);
|
||||||
egui::ProgressBar::new(item.percent())
|
if item.total > 0 && item.state != DownloadState::Completed {
|
||||||
.desired_width(140.0)
|
ui.add_space(theme::space::M);
|
||||||
.show_percentage(),
|
ui.label(ui::muted(format!(
|
||||||
);
|
"{} / {}",
|
||||||
ui.label(ui::muted(format!(
|
size(item.downloaded),
|
||||||
"{} / {}",
|
size(item.total)
|
||||||
bytes(item.downloaded),
|
)));
|
||||||
bytes(item.total)
|
let width = (ui.available_width() - 8.0).clamp(40.0, 320.0);
|
||||||
)));
|
ui::progress_bar(ui, item.percent(), width);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
ui.with_layout(
|
|
||||||
egui::Layout::right_to_left(egui::Align::Center),
|
|
||||||
|ui| match item.state {
|
|
||||||
DownloadState::Queued | DownloadState::Running => {
|
|
||||||
if ui
|
|
||||||
.button("Cancel")
|
|
||||||
.on_hover_text("Stop and keep partial")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::CancelDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
if ui.button("Pause").clicked() {
|
|
||||||
actions.push(Action::PauseDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DownloadState::Paused => {
|
|
||||||
if ui
|
|
||||||
.button("Cancel")
|
|
||||||
.on_hover_text("Stop and keep partial")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::CancelDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
if ui.button("Resume").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
|
||||||
if ui.button("Delete partial…").clicked() {
|
|
||||||
actions.push(Action::RemoveDownload(item.key.clone(), true));
|
|
||||||
}
|
|
||||||
if ui.button("Resume / Retry").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(item.key.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DownloadState::Completed => {
|
|
||||||
if ui
|
|
||||||
.button("Delete")
|
|
||||||
.on_hover_text("Delete the offline copy; keep the online source")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::RemoveDownload(item.key.clone(), false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a row says about itself, and in what colour.
|
||||||
|
fn status(item: &DownloadItem) -> (String, egui::Color32) {
|
||||||
|
match &item.state {
|
||||||
|
DownloadState::Queued => ("Waiting".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Running => {
|
||||||
|
let mut text = format!("{}/s", size(item.rate as u64));
|
||||||
|
if let Some(eta) = item.eta() {
|
||||||
|
text.push_str(&format!(" · {} left", ui::format_time(eta)));
|
||||||
|
}
|
||||||
|
(text, theme::text())
|
||||||
|
}
|
||||||
|
DownloadState::Paused => ("Paused".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Completed => ("Available offline".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Cancelled => ("Stopped · progress kept".to_owned(), theme::muted()),
|
||||||
|
DownloadState::Failed(error) => (format!("Failed: {error}"), theme::danger()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||||
|
let key = || item.key.clone();
|
||||||
|
match item.state {
|
||||||
|
DownloadState::Queued | DownloadState::Running => {
|
||||||
|
if ui
|
||||||
|
.button("Cancel")
|
||||||
|
.on_hover_text("Stop, and keep what has downloaded")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::CancelDownload(key()));
|
||||||
|
}
|
||||||
|
if ui.button("Pause").clicked() {
|
||||||
|
actions.push(Action::PauseDownload(key()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Paused => {
|
||||||
|
if ui
|
||||||
|
.button("Cancel")
|
||||||
|
.on_hover_text("Stop, and keep what has downloaded")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::CancelDownload(key()));
|
||||||
|
}
|
||||||
|
if ui.button("Resume").clicked() {
|
||||||
|
actions.push(Action::ResumeDownload(key()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
||||||
|
if ui
|
||||||
|
.button("Delete")
|
||||||
|
.on_hover_text("Discard what has downloaded so far")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key(), true));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(if matches!(item.state, DownloadState::Failed(_)) {
|
||||||
|
"Retry"
|
||||||
|
} else {
|
||||||
|
"Resume"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ResumeDownload(key()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::Completed => {
|
||||||
|
if ui
|
||||||
|
.button("Delete")
|
||||||
|
.on_hover_text("Delete the offline copy; keep the online source")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key(), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fraction(downloaded: u64, total: u64) -> f32 {
|
fn fraction(downloaded: u64, total: u64) -> f32 {
|
||||||
@@ -167,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bytes(value: u64) -> String {
|
fn size(bytes: u64) -> String {
|
||||||
const KIB: f64 = 1024.0;
|
ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX))
|
||||||
const MIB: f64 = 1024.0 * KIB;
|
|
||||||
const GIB: f64 = 1024.0 * MIB;
|
|
||||||
let value = value as f64;
|
|
||||||
if value >= GIB {
|
|
||||||
format!("{:.1} GiB", value / GIB)
|
|
||||||
} else if value >= MIB {
|
|
||||||
format!("{:.1} MiB", value / MIB)
|
|
||||||
} else if value >= KIB {
|
|
||||||
format!("{:.1} KiB", value / KIB)
|
|
||||||
} else {
|
|
||||||
format!("{value:.0} B")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! Everything that has been played, newest first, under a heading per day.
|
||||||
|
//!
|
||||||
|
//! Continue watching answers "what was I in the middle of"; this answers
|
||||||
|
//! "what was that episode on Tuesday". It is read straight off the watch
|
||||||
|
//! state, so there is nothing to keep in step: an episode is here because it
|
||||||
|
//! has a position or is marked watched, and leaves when it has neither.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local, NaiveDate, TimeZone};
|
||||||
|
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||||
|
|
||||||
|
use crate::app::{Action, Page};
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui::{self, Art};
|
||||||
|
|
||||||
|
const STILL: egui::Vec2 = egui::vec2(128.0, 72.0);
|
||||||
|
/// How far back the page goes. Further than anyone scrolls; the cap is there
|
||||||
|
/// so a library with years of history does not lay out every row of it.
|
||||||
|
const MOST: usize = 400;
|
||||||
|
|
||||||
|
/// Where one entry lives in [`Library::titles`]: title, season, episode.
|
||||||
|
pub type Entry = (usize, usize, usize);
|
||||||
|
|
||||||
|
/// The entries of `library.history()`, as positions, so they can be kept
|
||||||
|
/// between frames and only rebuilt when the library changes.
|
||||||
|
pub fn entries(library: &Library) -> Vec<Entry> {
|
||||||
|
let mut position = std::collections::HashMap::new();
|
||||||
|
for (t, title) in library.titles.iter().enumerate() {
|
||||||
|
for (s, season) in title.seasons.iter().enumerate() {
|
||||||
|
for (e, episode) in season.episodes.iter().enumerate() {
|
||||||
|
position.insert(std::ptr::from_ref(episode), (t, s, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
library
|
||||||
|
.history()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, episode)| position.get(&std::ptr::from_ref(episode)).copied())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
library: &Library,
|
||||||
|
entries: &[Entry],
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
if entries.is_empty() {
|
||||||
|
ui::empty_state(
|
||||||
|
ui,
|
||||||
|
"Nothing watched yet",
|
||||||
|
"Episodes and films you play show up here, newest first, so the one from \
|
||||||
|
last week is easy to find again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_max_width(960.0);
|
||||||
|
let mut day = None;
|
||||||
|
for &(t, s, e) in entries.iter().take(MOST) {
|
||||||
|
let Some(title) = library.titles.get(t) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(episode) = title
|
||||||
|
.seasons
|
||||||
|
.get(s)
|
||||||
|
.and_then(|season| season.episodes.get(e))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(at) = Local.timestamp_opt(episode.last_played(), 0).single() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if day != Some(at.date_naive()) {
|
||||||
|
if day.is_some() {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
|
day = Some(at.date_naive());
|
||||||
|
ui::section(ui, &day_label(at.date_naive(), today));
|
||||||
|
}
|
||||||
|
row(ui, art, title, episode, at, actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
episode: &Episode,
|
||||||
|
at: DateTime<Local>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let row_id = ui.id().with(("history", &episode.node.link_id));
|
||||||
|
let frame = egui::Frame::new()
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::same(8));
|
||||||
|
let mut prepared = frame.begin(ui);
|
||||||
|
{
|
||||||
|
let ui = &mut prepared.content_ui;
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui::title::still(
|
||||||
|
ui,
|
||||||
|
art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
episode.is_watched(),
|
||||||
|
STILL,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
|
let remove = 40.0;
|
||||||
|
let text_width = (ui.available_width() - remove).max(120.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(text_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = theme::space::XXS;
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
let name = ui
|
||||||
|
.add(
|
||||||
|
egui::Label::new(theme::Role::Body.rich(&title.name).color(theme::text()))
|
||||||
|
.sense(egui::Sense::click())
|
||||||
|
.truncate(),
|
||||||
|
)
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Show this title");
|
||||||
|
if name.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
// A film's only "episode" is its file, and its filename says
|
||||||
|
// nothing the title above it does not.
|
||||||
|
if title.kind != TitleKind::Film {
|
||||||
|
let episode_name = art
|
||||||
|
.episode(&title.key, episode)
|
||||||
|
.and_then(|found| found.name.clone())
|
||||||
|
.unwrap_or_else(|| episode.detail().to_owned());
|
||||||
|
let line = match episode.numbering() {
|
||||||
|
Some(numbering) => format!("{numbering} · {episode_name}"),
|
||||||
|
None => episode_name,
|
||||||
|
};
|
||||||
|
ui.add(egui::Label::new(ui::muted(line)).truncate())
|
||||||
|
.on_hover_text(&episode.node.name);
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(status(episode, at)));
|
||||||
|
});
|
||||||
|
|
||||||
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
|
let remove = ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::X)
|
||||||
|
.color(theme::muted()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.on_hover_text(if episode.is_watched() {
|
||||||
|
"Remove from history — marks it unwatched"
|
||||||
|
} else {
|
||||||
|
"Remove from history — forgets where you stopped"
|
||||||
|
});
|
||||||
|
if remove.clicked() {
|
||||||
|
actions.push(Action::RemoveFromHistory {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let response = prepared.allocate_space(ui);
|
||||||
|
let lit =
|
||||||
|
ui.ctx()
|
||||||
|
.animate_bool_with_time(row_id, response.contains_pointer(), theme::motion::HOVER);
|
||||||
|
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
||||||
|
prepared.paint(ui);
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grey line under the names: how far it got, and when.
|
||||||
|
fn status(episode: &Episode, at: DateTime<Local>) -> String {
|
||||||
|
let when = at.format("%H:%M");
|
||||||
|
if episode.is_watched() {
|
||||||
|
return format!("Watched · {when}");
|
||||||
|
}
|
||||||
|
match (
|
||||||
|
episode.resume_at(),
|
||||||
|
episode.watch.and_then(|watch| watch.duration_secs),
|
||||||
|
) {
|
||||||
|
(Some(position), Some(duration)) => format!(
|
||||||
|
"Stopped at {} of {} · {when}",
|
||||||
|
ui::format_time(position),
|
||||||
|
ui::format_time(duration)
|
||||||
|
),
|
||||||
|
(Some(position), None) => format!("Stopped at {} · {when}", ui::format_time(position)),
|
||||||
|
_ => format!("Played · {when}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading over a day's entries: "Today", "Yesterday", a weekday within
|
||||||
|
/// the last week, and a date before that — with the year only when it is not
|
||||||
|
/// this one.
|
||||||
|
fn day_label(day: NaiveDate, today: NaiveDate) -> String {
|
||||||
|
use chrono::Datelike;
|
||||||
|
match (today - day).num_days() {
|
||||||
|
0 => "Today".into(),
|
||||||
|
1 => "Yesterday".into(),
|
||||||
|
2..=6 => day.format("%A").to_string(),
|
||||||
|
_ if day.year() == today.year() => day.format("%-d %B").to_string(),
|
||||||
|
_ => day.format("%-d %B %Y").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_day_is_named_relative_to_today_for_the_last_week_and_dated_before_that() {
|
||||||
|
let today = date(2026, 10, 2);
|
||||||
|
assert_eq!(day_label(today, today), "Today");
|
||||||
|
assert_eq!(day_label(date(2026, 10, 1), today), "Yesterday");
|
||||||
|
assert_eq!(day_label(date(2026, 9, 28), today), "Monday");
|
||||||
|
assert_eq!(day_label(date(2026, 9, 25), today), "25 September");
|
||||||
|
assert_eq!(day_label(date(2025, 12, 24), today), "24 December 2025");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,53 +2,87 @@
|
|||||||
|
|
||||||
use pstr_core::library::{Library, Title, TitleKind};
|
use pstr_core::library::{Library, Title, TitleKind};
|
||||||
|
|
||||||
use crate::app::{Action, Page};
|
use crate::app::{Action, Filter, LibraryView, Page, Sort};
|
||||||
|
use crate::playback::PlaybackTarget;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui::Art;
|
use crate::ui::Art;
|
||||||
use crate::ui::{self, Card};
|
use crate::ui::{self, Card};
|
||||||
|
|
||||||
pub fn show(
|
/// What the page lists, and what it was asked for.
|
||||||
ui: &mut egui::Ui,
|
pub struct Shelves<'a> {
|
||||||
art: &mut Art<'_>,
|
pub library: &'a Library,
|
||||||
library: &Library,
|
/// The search and the shelf, already worked out — see [`LibraryView`].
|
||||||
search: &str,
|
pub view: &'a LibraryView,
|
||||||
actions: &mut Vec<Action>,
|
/// Whether the catalog has been read yet.
|
||||||
) {
|
pub loaded: bool,
|
||||||
|
pub search: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec<Action>) {
|
||||||
|
let Shelves {
|
||||||
|
library,
|
||||||
|
view,
|
||||||
|
loaded,
|
||||||
|
search,
|
||||||
|
} = shelves;
|
||||||
|
if !loaded {
|
||||||
|
return placeholder(ui);
|
||||||
|
}
|
||||||
if library.is_empty() {
|
if library.is_empty() {
|
||||||
return empty_state(ui, actions);
|
return empty_state(ui, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
let matches = library.search(search);
|
let pick = |indices: &[usize]| -> Vec<&Title> {
|
||||||
|
indices
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&index| library.titles.get(index))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let matches = pick(&view.matches);
|
||||||
let searching = !search.trim().is_empty();
|
let searching = !search.trim().is_empty();
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
if !searching {
|
if !searching {
|
||||||
let resumable = library.continue_watching();
|
if let Some(title) = featured(library, view, art.metadata, today()) {
|
||||||
|
hero(ui, art, title, actions);
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
}
|
||||||
|
let resumable = pick(&view.resumable);
|
||||||
if !resumable.is_empty() {
|
if !resumable.is_empty() {
|
||||||
ui::section(ui, "Continue watching");
|
ui::section(ui, "Continue watching");
|
||||||
continue_row(ui, art, &resumable, actions);
|
continue_row(ui, art, &resumable, actions);
|
||||||
ui.add_space(16.0);
|
ui.add_space(theme::space::XL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::section(
|
ui.horizontal(|ui| {
|
||||||
ui,
|
ui::section(
|
||||||
&if searching {
|
ui,
|
||||||
format!(
|
&if searching {
|
||||||
"{} matching {:?}",
|
format!(
|
||||||
plural(matches.len(), "title"),
|
"{} matching {:?}",
|
||||||
search.trim()
|
plural(matches.len(), "title"),
|
||||||
)
|
search.trim()
|
||||||
} else {
|
)
|
||||||
plural(matches.len(), "title")
|
} else {
|
||||||
},
|
plural(matches.len(), "title")
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
shelf_controls(ui, view, actions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.label(ui::muted("Nothing here by that name."));
|
ui.label(ui::muted(if searching {
|
||||||
|
"Nothing here by that name."
|
||||||
|
} else {
|
||||||
|
"Nothing in the library fits that filter."
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +90,224 @@ pub fn show(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Days since the epoch: what the featured title turns over on.
|
||||||
|
fn today() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |since| since.as_secs() / 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title the banner shows: whatever was watched last, so the top of the
|
||||||
|
/// page is one click from carrying on. With nothing part-watched, one of the
|
||||||
|
/// titles a provider has a backdrop for, turning over once a day — the same
|
||||||
|
/// one every time the page is opened today, rather than a new one per frame.
|
||||||
|
fn featured<'a>(
|
||||||
|
library: &'a Library,
|
||||||
|
view: &LibraryView,
|
||||||
|
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
|
||||||
|
day: u64,
|
||||||
|
) -> Option<&'a Title> {
|
||||||
|
if let Some(title) = view
|
||||||
|
.resumable
|
||||||
|
.first()
|
||||||
|
.and_then(|&index| library.titles.get(index))
|
||||||
|
{
|
||||||
|
return Some(title);
|
||||||
|
}
|
||||||
|
let pictured: Vec<&Title> = library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| {
|
||||||
|
metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.as_ref())
|
||||||
|
.is_some_and(|found| found.backdrop_url.is_some())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if pictured.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pictured
|
||||||
|
.get((day % pictured.len() as u64) as usize)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The banner across the top of the library: one title, large, with what it
|
||||||
|
/// is and a way to play it.
|
||||||
|
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
|
||||||
|
let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(
|
||||||
|
egui::vec2(ui.available_width(), height),
|
||||||
|
egui::Sense::click(),
|
||||||
|
);
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
|
let picture = art.of(title);
|
||||||
|
let found = art
|
||||||
|
.metadata
|
||||||
|
.get(&title.key)
|
||||||
|
.and_then(|record| record.metadata.clone());
|
||||||
|
|
||||||
|
let painter = ui.painter().with_clip_rect(rect);
|
||||||
|
painter.rect_filled(rect, radius, theme::card());
|
||||||
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("art"),
|
||||||
|
picture.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
|
if art_in < 1.0 {
|
||||||
|
ui::placeholder(&painter, rect, "", 1.0 - art_in);
|
||||||
|
}
|
||||||
|
if let Some((texture, _)) = &picture {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Color32::WHITE.gamma_multiply(art_in),
|
||||||
|
)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The same two coats as the title page's band, for the same reason: a
|
||||||
|
// still is arbitrary, and the text over it has to read whatever it is.
|
||||||
|
if theme::ramps_on() {
|
||||||
|
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25));
|
||||||
|
let text_side = egui::Rect::from_min_max(
|
||||||
|
rect.min,
|
||||||
|
egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()),
|
||||||
|
);
|
||||||
|
painter.add(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
text_side,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Horizontal,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
let bottom =
|
||||||
|
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max);
|
||||||
|
painter.add(theme::fade_shape(
|
||||||
|
ui.ctx(),
|
||||||
|
bottom,
|
||||||
|
theme::background(),
|
||||||
|
theme::Direction::Vertical,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = egui::Rect::from_min_max(
|
||||||
|
rect.min + egui::vec2(32.0, 0.0),
|
||||||
|
egui::pos2(
|
||||||
|
rect.left() + (rect.width() * 0.55).max(360.0),
|
||||||
|
rect.bottom() - 28.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ui.scope_builder(
|
||||||
|
egui::UiBuilder::new()
|
||||||
|
.max_rect(inner)
|
||||||
|
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|
||||||
|
|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if let Some(episode) = title.next_up() {
|
||||||
|
let label = match (title.resume(), episode.numbering()) {
|
||||||
|
(Some(_), Some(numbering)) => {
|
||||||
|
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
|
||||||
|
}
|
||||||
|
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
|
||||||
|
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
|
||||||
|
};
|
||||||
|
if ui::accent_button(ui, &label).clicked() {
|
||||||
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
|
if ui.button("More info").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
overview.to_owned(),
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
theme::text(),
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 3;
|
||||||
|
ui.label(job);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
}
|
||||||
|
let mut facts = vec![subtitle(title)];
|
||||||
|
if let Some(found) = &found {
|
||||||
|
if let Some(rating) = found.rating {
|
||||||
|
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
|
}
|
||||||
|
facts.extend(found.genres.iter().take(3).cloned());
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(facts.join(" · ")));
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Display
|
||||||
|
.rich(&title.name)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
)
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if response.clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filter pills and the order, at the right of the grid's heading.
|
||||||
|
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
||||||
|
// Right to left: the order first, so it ends up last.
|
||||||
|
let sort = egui::ComboBox::from_id_salt("library-sort")
|
||||||
|
.selected_text(match view.sort {
|
||||||
|
Sort::Name => "A – Z",
|
||||||
|
Sort::Recent => "Recently watched",
|
||||||
|
Sort::Year => "Newest",
|
||||||
|
})
|
||||||
|
.width(150.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
let mut picked = None;
|
||||||
|
for (sort, label) in [
|
||||||
|
(Sort::Name, "A – Z"),
|
||||||
|
(Sort::Recent, "Recently watched"),
|
||||||
|
(Sort::Year, "Newest"),
|
||||||
|
] {
|
||||||
|
if ui.selectable_label(view.sort == sort, label).clicked() {
|
||||||
|
picked = Some(sort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picked
|
||||||
|
});
|
||||||
|
if let Some(Some(sort)) = sort.inner {
|
||||||
|
actions.push(Action::SetShelf(view.filter, sort));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
if let Some(filter) = ui::widgets::segmented(
|
||||||
|
ui,
|
||||||
|
view.filter,
|
||||||
|
&[
|
||||||
|
(Filter::All, "All"),
|
||||||
|
(Filter::Series, "Series"),
|
||||||
|
(Filter::Films, "Films"),
|
||||||
|
(Filter::Watching, "Watching"),
|
||||||
|
(Filter::Unwatched, "Not started"),
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
actions.push(Action::SetShelf(filter, view.sort));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The top shelf: one card per part-watched title, scrolling sideways.
|
/// The top shelf: one card per part-watched title, scrolling sideways.
|
||||||
fn continue_row(
|
fn continue_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -85,7 +337,7 @@ fn continue_row(
|
|||||||
})
|
})
|
||||||
.unwrap_or_else(|| episode.label());
|
.unwrap_or_else(|| episode.label());
|
||||||
|
|
||||||
let clicked = ui::card(
|
let response = ui::card(
|
||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: art.of(title),
|
art: art.of(title),
|
||||||
@@ -97,10 +349,10 @@ fn continue_row(
|
|||||||
// to reach, so nothing to flex to.
|
// to reach, so nothing to flex to.
|
||||||
width: theme::CARD_WIDTH,
|
width: theme::CARD_WIDTH,
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.clicked();
|
tile_menu(&response, title, true, actions);
|
||||||
|
|
||||||
if clicked {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +363,22 @@ fn continue_row(
|
|||||||
/// Every title, wrapped to the window.
|
/// Every title, wrapped to the window.
|
||||||
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
|
||||||
let grid = ui::columns(ui.available_width());
|
let grid = ui::columns(ui.available_width());
|
||||||
|
let row_height = ui::card_height(grid.width);
|
||||||
for row in titles.chunks(grid.columns) {
|
for row in titles.chunks(grid.columns) {
|
||||||
|
// A row scrolled out of view is only its height. Laying it out anyway
|
||||||
|
// costs a subtitle and an artwork lookup per card per frame, and while
|
||||||
|
// a film plays under the transport bar a frame is every frame.
|
||||||
|
let slot = egui::Rect::from_min_size(
|
||||||
|
ui.cursor().min,
|
||||||
|
egui::vec2(ui.available_width(), row_height),
|
||||||
|
);
|
||||||
|
if !ui.is_rect_visible(slot) {
|
||||||
|
// The spacing `horizontal` would have added after itself, too, or
|
||||||
|
// the page grows and shrinks under the scroll bar as rows come and
|
||||||
|
// go.
|
||||||
|
ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
// The gap the width was divided around, so what is drawn matches
|
// The gap the width was divided around, so what is drawn matches
|
||||||
// what was measured. egui's default item spacing is narrower, and
|
// what was measured. egui's default item spacing is narrower, and
|
||||||
@@ -119,7 +386,7 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
// the left edge.
|
// the left edge.
|
||||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
for title in row {
|
for title in row {
|
||||||
let clicked = ui::card(
|
let response = ui::card(
|
||||||
ui,
|
ui,
|
||||||
Card {
|
Card {
|
||||||
art: art.of(title),
|
art: art.of(title),
|
||||||
@@ -131,10 +398,10 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
badge: None,
|
badge: None,
|
||||||
width: grid.width,
|
width: grid.width,
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.clicked();
|
tile_menu(&response, title, false, actions);
|
||||||
|
|
||||||
if clicked {
|
if response.clicked() {
|
||||||
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +410,80 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a right-click on a tile offers: the things otherwise one page away.
|
||||||
|
///
|
||||||
|
/// `continuing` is the Continue watching shelf, where the tile can also be
|
||||||
|
/// taken off the shelf.
|
||||||
|
fn tile_menu(
|
||||||
|
response: &egui::Response,
|
||||||
|
title: &Title,
|
||||||
|
continuing: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
response.context_menu(|ui| {
|
||||||
|
if let Some(episode) = title.next_up() {
|
||||||
|
let label = match (title.resume(), episode.numbering()) {
|
||||||
|
(Some(_), Some(numbering)) => format!("Resume {numbering}"),
|
||||||
|
(Some(_), None) => "Resume".to_owned(),
|
||||||
|
(None, _) => "Play".to_owned(),
|
||||||
|
};
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("Open").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Title(title.key.clone())));
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let all_watched = title.watched_count() == title.episode_count();
|
||||||
|
if ui
|
||||||
|
.button(if all_watched {
|
||||||
|
"Mark unwatched"
|
||||||
|
} else {
|
||||||
|
"Mark watched"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetTitleWatched {
|
||||||
|
key: title.key.clone(),
|
||||||
|
watched: !all_watched,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if continuing
|
||||||
|
&& let Some(episode) = title.resume()
|
||||||
|
&& ui
|
||||||
|
.button("Remove from Continue watching")
|
||||||
|
.on_hover_text("Forget where you stopped in it")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ForgetPosition {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = title.episode_count();
|
||||||
|
let download = if count == 1 {
|
||||||
|
"Download".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("Download all {count}")
|
||||||
|
};
|
||||||
|
if ui.button(download).clicked() {
|
||||||
|
actions.push(Action::MakeOffline(
|
||||||
|
title
|
||||||
|
.episodes()
|
||||||
|
.map(|episode| PlaybackTarget::new(title, episode))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Change match…").clicked() {
|
||||||
|
actions.push(Action::OpenMatcher(title.key.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The grey line under a card: what it is, how much there is, and how much is
|
/// The grey line under a card: what it is, how much there is, and how much is
|
||||||
/// left.
|
/// left.
|
||||||
///
|
///
|
||||||
@@ -168,6 +509,37 @@ fn subtitle(title: &Title) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page before the catalog has been read: the shape of a grid, with
|
||||||
|
/// nothing in it yet.
|
||||||
|
///
|
||||||
|
/// Not the empty state. The catalog is on disk and is read in a fraction of a
|
||||||
|
/// second, and telling every launch "Nothing in the library yet" for that
|
||||||
|
/// fraction is a flash of something false.
|
||||||
|
fn placeholder(ui: &mut egui::Ui) {
|
||||||
|
let grid = ui::columns(ui.available_width());
|
||||||
|
let height = ui::card_height(grid.width);
|
||||||
|
ui::section(ui, "Library");
|
||||||
|
for _ in 0..2 {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||||
|
for _ in 0..grid.columns {
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover());
|
||||||
|
let image = egui::Rect::from_min_size(
|
||||||
|
rect.min,
|
||||||
|
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
|
||||||
|
);
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
image,
|
||||||
|
egui::CornerRadius::same(theme::radius::MD),
|
||||||
|
theme::card(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::CARD_GAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.add_space(120.0);
|
ui.add_space(120.0);
|
||||||
@@ -176,11 +548,11 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
|||||||
.rich("Nothing in the library yet")
|
.rich("Nothing in the library yet")
|
||||||
.strong(),
|
.strong(),
|
||||||
);
|
);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
"Add a Proton Drive share link, and everything playable behind it shows up here.",
|
||||||
));
|
));
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
if ui::accent_button(ui, "Add a share").clicked() {
|
if ui::accent_button(ui, "Add a share").clicked() {
|
||||||
actions.push(Action::Goto(Page::Shares));
|
actions.push(Action::Goto(Page::Shares));
|
||||||
}
|
}
|
||||||
@@ -225,6 +597,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_banner_features_the_title_watched_last_and_nothing_without_a_picture() {
|
||||||
|
let mut second = title(TitleKind::Film, None);
|
||||||
|
second.key = "second".into();
|
||||||
|
let library = Library {
|
||||||
|
titles: vec![title(TitleKind::Film, None), second],
|
||||||
|
};
|
||||||
|
let metadata = std::collections::HashMap::new();
|
||||||
|
let mut view = LibraryView::default();
|
||||||
|
assert!(featured(&library, &view, &metadata, 0).is_none());
|
||||||
|
view.resumable = vec![1];
|
||||||
|
assert_eq!(
|
||||||
|
featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()),
|
||||||
|
Some("second")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn plurals_read_naturally() {
|
fn plurals_read_naturally() {
|
||||||
assert_eq!(plural(1, "title"), "1 title");
|
assert_eq!(plural(1, "title"), "1 title");
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub fn show(
|
|||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::surface())
|
.fill(theme::surface())
|
||||||
.inner_margin(egui::Margin::same(18))
|
.inner_margin(egui::Margin::same(18))
|
||||||
.corner_radius(egui::CornerRadius::same(10)),
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
|
||||||
)
|
)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(620.0);
|
ui.set_width(620.0);
|
||||||
@@ -58,21 +58,21 @@ pub fn show(
|
|||||||
"Everything {} answers with, in its own order — nothing is scored here.",
|
"Everything {} answers with, in its own order — nothing is scored here.",
|
||||||
provider.label()
|
provider.label()
|
||||||
)));
|
)));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
search_row(ui, matcher, actions);
|
search_row(ui, matcher, actions);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
current_row(ui, matcher, current.as_ref(), actions);
|
current_row(ui, matcher, current.as_ref(), actions);
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
if let Some(error) = &matcher.error {
|
if let Some(error) = &matcher.error {
|
||||||
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
results(ui, matcher, art, actions);
|
results(ui, matcher, art, actions);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if ui.button("Close").clicked() {
|
if ui.button("Close").clicked() {
|
||||||
@@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
|
|||||||
if row(ui, art, found).clicked() {
|
if row(ui, art, found).clicked() {
|
||||||
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
actions.push(Action::ChooseMatch(Box::new(found.clone())));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu
|
|||||||
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
|
||||||
let response = egui::Frame::new()
|
let response = egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
.corner_radius(egui::CornerRadius::same(theme::radius::SM))
|
||||||
.inner_margin(egui::Margin::same(8))
|
.inner_margin(egui::Margin::same(8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
@@ -204,12 +204,12 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
} else {
|
} else {
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::CornerRadius::same(4),
|
egui::CornerRadius::same(theme::radius::BADGE),
|
||||||
theme::card_hover(),
|
theme::card_hover(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Body
|
theme::Role::Body
|
||||||
@@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res
|
|||||||
if response.hovered() {
|
if response.hovered() {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
response.rect,
|
response.rect,
|
||||||
egui::CornerRadius::same(6),
|
egui::CornerRadius::same(theme::radius::SM),
|
||||||
egui::Stroke::new(1.5, theme::accent()),
|
egui::Stroke::new(1.5, theme::accent()),
|
||||||
egui::StrokeKind::Inside,
|
egui::StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
@@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
|
|||||||
.or_else(|| found.backdrop_url.clone())?;
|
.or_else(|| found.backdrop_url.clone())?;
|
||||||
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
|
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
|
||||||
let engine = art.engine;
|
let engine = art.engine;
|
||||||
let requested = key.clone();
|
|
||||||
art.posters
|
art.posters
|
||||||
.texture(key, || engine.request_poster(requested, url))
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
|
||||||
@@ -280,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
|
|||||||
parts.push(ui::library::plural(episodes as usize, "episode"));
|
parts.push(ui::library::plural(episodes as usize, "episode"));
|
||||||
}
|
}
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("★ {rating:.1}"));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,17 @@
|
|||||||
//! applies them after the frame is drawn.
|
//! applies them after the frame is drawn.
|
||||||
|
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
|
pub mod history;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod matcher;
|
pub mod matcher;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod shares;
|
pub mod shares;
|
||||||
|
pub mod shortcuts;
|
||||||
pub mod title;
|
pub mod title;
|
||||||
|
pub mod toast;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -48,6 +52,28 @@ impl Art<'_> {
|
|||||||
.get(episode.node.parsed.season, number)
|
.get(episode.node.parsed.season, number)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A still for one episode: the provider's, else Proton's own thumbnail of
|
||||||
|
/// the file. Only asked for by rows that are on screen.
|
||||||
|
pub fn still(&mut self, title_key: &str, episode: &Episode) -> Option<egui::TextureHandle> {
|
||||||
|
let url = self
|
||||||
|
.episode(title_key, episode)
|
||||||
|
.and_then(|found| found.still_url.clone());
|
||||||
|
if let Some(url) = url {
|
||||||
|
let key = format!("still:{}", url);
|
||||||
|
let engine = self.engine;
|
||||||
|
if let Some(texture) = self
|
||||||
|
.posters
|
||||||
|
.texture(&key, || engine.request_poster(key.clone(), url))
|
||||||
|
{
|
||||||
|
return Some(texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let node = &episode.node;
|
||||||
|
let engine = self.engine;
|
||||||
|
self.thumbs
|
||||||
|
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
|
||||||
|
}
|
||||||
|
|
||||||
/// The picture for a title, and how to fit it.
|
/// The picture for a title, and how to fit it.
|
||||||
///
|
///
|
||||||
/// Provider artwork first, then Proton's still, then nothing. That order is
|
/// Provider artwork first, then Proton's still, then nothing. That order is
|
||||||
@@ -62,12 +88,9 @@ impl Art<'_> {
|
|||||||
.and_then(|metadata| metadata.tile_art())
|
.and_then(|metadata| metadata.tile_art())
|
||||||
{
|
{
|
||||||
let engine = self.engine;
|
let engine = self.engine;
|
||||||
let key = title.key.clone();
|
if let Some(texture) = self.posters.texture(&title.key, || {
|
||||||
let url = url.to_string();
|
engine.request_poster(title.key.clone(), url.to_string())
|
||||||
if let Some(texture) = self
|
}) {
|
||||||
.posters
|
|
||||||
.texture(title.key.clone(), || engine.request_poster(key, url))
|
|
||||||
{
|
|
||||||
return Some((texture, shape));
|
return Some((texture, shape));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +99,7 @@ impl Art<'_> {
|
|||||||
let engine = self.engine;
|
let engine = self.engine;
|
||||||
let texture = self
|
let texture = self
|
||||||
.thumbs
|
.thumbs
|
||||||
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
|
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))?;
|
||||||
Some((texture, ArtShape::Landscape))
|
Some((texture, ArtShape::Landscape))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,6 +118,12 @@ pub fn format_time(seconds: f64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `1.25×`, or `1×`.
|
||||||
|
pub fn format_speed(speed: f64) -> String {
|
||||||
|
let text = format!("{speed:.2}");
|
||||||
|
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
|
||||||
|
}
|
||||||
|
|
||||||
/// `1.4 GiB`, for what a file costs to watch.
|
/// `1.4 GiB`, for what a file costs to watch.
|
||||||
pub fn format_size(bytes: i64) -> String {
|
pub fn format_size(bytes: i64) -> String {
|
||||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||||
@@ -113,14 +142,14 @@ pub fn format_size(bytes: i64) -> String {
|
|||||||
|
|
||||||
/// A section title with a rule under it.
|
/// A section title with a rule under it.
|
||||||
pub fn section(ui: &mut egui::Ui, text: &str) {
|
pub fn section(ui: &mut egui::Ui, text: &str) {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Section
|
theme::Role::Section
|
||||||
.rich(text)
|
.rich(text)
|
||||||
.strong()
|
.strong()
|
||||||
.color(theme::text()),
|
.color(theme::text()),
|
||||||
);
|
);
|
||||||
ui.add_space(2.0);
|
ui.add_space(theme::space::XXS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Small grey text, for everything that is context rather than content.
|
/// Small grey text, for everything that is context rather than content.
|
||||||
@@ -133,6 +162,184 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
|||||||
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The search box in the top bar: a magnifier, the text, and a way to clear it.
|
||||||
|
///
|
||||||
|
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
||||||
|
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
|
||||||
|
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||||
|
let id = egui::Id::new("library-search");
|
||||||
|
let width = 240.0;
|
||||||
|
let height = ui.spacing().interact_size.y;
|
||||||
|
let (outer, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
|
||||||
|
let has_focus = ui.memory(|memory| memory.has_focus(id));
|
||||||
|
let lit = ui
|
||||||
|
.ctx()
|
||||||
|
.animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER);
|
||||||
|
|
||||||
|
let radius = CornerRadius::same((height / 2.0) as u8);
|
||||||
|
ui.painter().rect_filled(outer, radius, theme::card());
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
outer,
|
||||||
|
radius,
|
||||||
|
Stroke::new(1.0, theme::card_hover().lerp_to_gamma(theme::accent(), lit)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A magnifier: a ring and a handle, so no font has to have the glyph.
|
||||||
|
let ink = theme::muted().lerp_to_gamma(theme::text(), lit);
|
||||||
|
let lens = egui::pos2(outer.left() + 16.0, outer.center().y - 1.0);
|
||||||
|
ui.painter().circle_stroke(lens, 5.0, Stroke::new(1.5, ink));
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[lens + Vec2::new(3.6, 3.6), lens + Vec2::new(7.0, 7.0)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
|
||||||
|
let clear_width = if search.is_empty() { 0.0 } else { 26.0 };
|
||||||
|
let field_rect = Rect::from_min_max(
|
||||||
|
egui::pos2(outer.left() + 28.0, outer.top()),
|
||||||
|
egui::pos2(outer.right() - 8.0 - clear_width, outer.bottom()),
|
||||||
|
);
|
||||||
|
let response = ui.put(
|
||||||
|
field_rect,
|
||||||
|
egui::TextEdit::singleline(search)
|
||||||
|
.id(id)
|
||||||
|
.frame(egui::Frame::NONE)
|
||||||
|
.hint_text(if has_focus {
|
||||||
|
"Titles and filenames"
|
||||||
|
} else {
|
||||||
|
"Search Ctrl+F"
|
||||||
|
})
|
||||||
|
.vertical_align(egui::Align::Center)
|
||||||
|
.desired_width(field_rect.width()),
|
||||||
|
);
|
||||||
|
if focus {
|
||||||
|
response.request_focus();
|
||||||
|
}
|
||||||
|
if response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||||
|
search.clear();
|
||||||
|
response.surrender_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !search.is_empty() {
|
||||||
|
let clear = Rect::from_center_size(
|
||||||
|
egui::pos2(outer.right() - 18.0, outer.center().y),
|
||||||
|
Vec2::splat(18.0),
|
||||||
|
);
|
||||||
|
let response = ui.interact(clear, id.with("clear"), Sense::click());
|
||||||
|
let ink = if response.hovered() {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
};
|
||||||
|
let c = clear.center();
|
||||||
|
let d = 3.5;
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
if response
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Clear (Esc)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
search.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thin bar in the accent, for how far something has got.
|
||||||
|
///
|
||||||
|
/// egui's own progress bar is one flat colour with the percentage written over
|
||||||
|
/// it; this is the same bar the tiles and the seek bar use, so a download reads
|
||||||
|
/// as part of the same app.
|
||||||
|
pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
|
ui.painter().rect_filled(rect, radius, theme::card_hover());
|
||||||
|
let mut done = rect;
|
||||||
|
done.set_width(rect.width() * fraction.clamp(0.0, 1.0));
|
||||||
|
if done.width() > 0.5 {
|
||||||
|
theme::accent_fill(ui.painter(), done, radius, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A centred message for a page with nothing on it: what is missing, and what
|
||||||
|
/// to do about it.
|
||||||
|
pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(96.0);
|
||||||
|
ui.label(theme::Role::Title.rich(heading).strong());
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(muted(body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The danger colour as a button: for the one choice in a dialog that cannot be
|
||||||
|
/// taken back.
|
||||||
|
pub fn danger_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
ui.add(
|
||||||
|
egui::Button::new(egui::RichText::new(text).color(theme::on_accent()))
|
||||||
|
.fill(theme::danger()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a viewer answered a [`confirm`] dialog with.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Answer {
|
||||||
|
/// Still open.
|
||||||
|
Pending,
|
||||||
|
Confirmed,
|
||||||
|
/// Cancelled — by the button, Escape, or a click beside the dialog.
|
||||||
|
Declined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A question with two answers, over everything else.
|
||||||
|
///
|
||||||
|
/// For the actions that destroy something the viewer cannot get back from
|
||||||
|
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
|
||||||
|
/// the click.
|
||||||
|
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
|
||||||
|
let mut answer = Answer::Pending;
|
||||||
|
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.inner_margin(egui::Margin::same(18))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG)),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(420.0);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Heading
|
||||||
|
.rich(heading)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
|
||||||
|
ui.add_space(theme::space::XL);
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if danger_button(ui, verb).clicked() {
|
||||||
|
answer = Answer::Confirmed;
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
answer = Answer::Declined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if answer == Answer::Pending && modal.should_close() {
|
||||||
|
answer = Answer::Declined;
|
||||||
|
}
|
||||||
|
answer
|
||||||
|
}
|
||||||
|
|
||||||
/// What [`filled`] paints behind its label.
|
/// What [`filled`] paints behind its label.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Fill {
|
enum Fill {
|
||||||
@@ -176,15 +383,17 @@ pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option<u
|
|||||||
// the pill from overshooting when it moves between tabs of different
|
// the pill from overshooting when it moves between tabs of different
|
||||||
// lengths — both edges arrive at the same time.
|
// lengths — both edges arrive at the same time.
|
||||||
let ctx = ui.ctx();
|
let ctx = ui.ctx();
|
||||||
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16);
|
let left =
|
||||||
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16);
|
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
|
||||||
|
let right =
|
||||||
|
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
|
||||||
let rect = Rect::from_min_max(
|
let rect = Rect::from_min_max(
|
||||||
egui::pos2(left, target.top()),
|
egui::pos2(left, target.top()),
|
||||||
egui::pos2(right, target.bottom()),
|
egui::pos2(right, target.bottom()),
|
||||||
);
|
);
|
||||||
ui.painter().set(
|
ui.painter().set(
|
||||||
pill,
|
pill,
|
||||||
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)),
|
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,17 +434,21 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
let radius = CornerRadius::same(8);
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
// Eased rather than switched, so the veil arrives over a few frames instead
|
// Eased rather than switched, so the veil arrives over a few frames instead
|
||||||
// of appearing whole the moment the pointer crosses the edge. The press is
|
// of appearing whole the moment the pointer crosses the edge. The press is
|
||||||
// quicker than the hover: a click should feel like it registered, not like
|
// quicker than the hover: a click should feel like it registered, not like
|
||||||
// it was considered.
|
// it was considered.
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10);
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
let press = ctx.animate_bool_with_time(
|
let press = ctx.animate_bool_with_time(
|
||||||
response.id.with("press"),
|
response.id.with("press"),
|
||||||
response.is_pointer_button_down_on(),
|
response.is_pointer_button_down_on(),
|
||||||
0.05,
|
theme::motion::PRESS,
|
||||||
);
|
);
|
||||||
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
|
let lift = 0.10 * hover * (1.0 - press) - 0.14 * press;
|
||||||
|
|
||||||
@@ -281,6 +494,17 @@ pub struct Card<'a> {
|
|||||||
pub width: f32,
|
pub width: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated
|
||||||
|
/// before the name is laid out — and because a grid whose rows are each a
|
||||||
|
/// different height by title length reads as broken.
|
||||||
|
const CARD_TEXT_HEIGHT: f32 = 54.0;
|
||||||
|
|
||||||
|
/// How tall [`card`] draws a tile of this width, so a grid can step over rows
|
||||||
|
/// it does not draw.
|
||||||
|
pub fn card_height(width: f32) -> f32 {
|
||||||
|
(width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT
|
||||||
|
}
|
||||||
|
|
||||||
/// Draw one tile and report whether it was clicked.
|
/// Draw one tile and report whether it was clicked.
|
||||||
///
|
///
|
||||||
/// A landscape picture is cropped to fill: a grid of differently-shaped black
|
/// A landscape picture is cropped to fill: a grid of differently-shaped black
|
||||||
@@ -292,14 +516,16 @@ pub struct Card<'a> {
|
|||||||
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
||||||
let width = card.width;
|
let width = card.width;
|
||||||
let image_height = (width * theme::CARD_ASPECT).round();
|
let image_height = (width * theme::CARD_ASPECT).round();
|
||||||
// Two lines of name plus one of subtitle. Fixed, because the tile is
|
let text_height = CARD_TEXT_HEIGHT;
|
||||||
// allocated before the name is laid out — and because a grid whose rows are
|
let radius = CornerRadius::same(theme::radius::MD);
|
||||||
// each a different height by title length reads as broken.
|
|
||||||
let text_height = 54.0;
|
|
||||||
let radius = CornerRadius::same(8);
|
|
||||||
|
|
||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
|
||||||
|
// Reached with the arrow keys or Tab: bring it into view, before the
|
||||||
|
// early return below would skip a tile that is just off screen.
|
||||||
|
if response.gained_focus() {
|
||||||
|
response.scroll_to_me(None);
|
||||||
|
}
|
||||||
if !ui.is_rect_visible(rect) {
|
if !ui.is_rect_visible(rect) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -309,15 +535,21 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
// ride it, so they arrive together rather than each snapping on its own.
|
// ride it, so they arrive together rather than each snapping on its own.
|
||||||
// egui drives this from wall-clock time, so it is the same speed whatever
|
// egui drives this from wall-clock time, so it is the same speed whatever
|
||||||
// the frame rate — and it needs no state kept here.
|
// the frame rate — and it needs no state kept here.
|
||||||
let hover =
|
// Focus lights a tile the way the pointer does, so the keyboard can be
|
||||||
ui.ctx()
|
// followed across the grid.
|
||||||
.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12);
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered() || response.has_focus(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
// The picture arrives whenever its download finishes, several frames after
|
// The picture arrives whenever its download finishes, several frames after
|
||||||
// the tile first drew. Fading it in over the placeholder turns a wall of
|
// the tile first drew. Fading it in over the placeholder turns a wall of
|
||||||
// letters popping into stills into one settle.
|
// letters popping into stills into one settle.
|
||||||
let art_in = ui
|
let art_in = ui.ctx().animate_bool_with_time(
|
||||||
.ctx()
|
response.id.with("art"),
|
||||||
.animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22);
|
card.art.is_some(),
|
||||||
|
theme::motion::ARRIVE,
|
||||||
|
);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
if hover > 0.0 {
|
if hover > 0.0 {
|
||||||
@@ -328,13 +560,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
// Under the picture, not instead of it: it is what shows through while the
|
// Under the picture, not instead of it: it is what shows through while the
|
||||||
// art fades in, and it is the whole tile when there is no art to come.
|
// art fades in, and it is the whole tile when there is no art to come.
|
||||||
if art_in < 1.0 {
|
if art_in < 1.0 {
|
||||||
painter.text(
|
placeholder(painter, image_rect, card.name, 1.0 - art_in);
|
||||||
image_rect.center(),
|
|
||||||
Align2::CENTER_CENTER,
|
|
||||||
initials(card.name),
|
|
||||||
egui::FontId::proportional(28.0),
|
|
||||||
theme::muted().gamma_multiply(1.0 - art_in),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// White because a textured rect *multiplies* its fill by the texture; the
|
// White because a textured rect *multiplies* its fill by the texture; the
|
||||||
@@ -374,7 +600,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
|
|||||||
);
|
);
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
background,
|
background,
|
||||||
CornerRadius::same(4),
|
CornerRadius::same(theme::radius::BADGE),
|
||||||
Color32::from_black_alpha(180),
|
Color32::from_black_alpha(180),
|
||||||
);
|
);
|
||||||
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
|
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
|
||||||
@@ -470,7 +696,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// UV rectangle that crops `image` to fill `target` without distorting it.
|
/// UV rectangle that crops `image` to fill `target` without distorting it.
|
||||||
fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
pub fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
||||||
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
|
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
|
||||||
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
||||||
}
|
}
|
||||||
@@ -485,14 +711,55 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect {
|
|||||||
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
|
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Up to two letters, for a tile with no picture.
|
/// What a tile with no picture shows: a gradient of its own, and its name.
|
||||||
fn initials(name: &str) -> String {
|
///
|
||||||
name.split_whitespace()
|
/// Two grey letters on a grey card was the whole tile for any title nothing
|
||||||
.filter_map(|word| word.chars().next())
|
/// knew a picture for — a wall of them read as a page that had failed to load.
|
||||||
.filter(|character| character.is_alphanumeric())
|
/// The colours come from the name, so a title wears the same ones every time
|
||||||
.take(2)
|
/// and two titles side by side rarely match.
|
||||||
.flat_map(char::to_uppercase)
|
///
|
||||||
.collect()
|
/// The hash is FNV-1a: stable across runs and platforms, which the standard
|
||||||
|
/// library's hasher deliberately is not.
|
||||||
|
pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opacity: f32) {
|
||||||
|
let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
|
||||||
|
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
|
||||||
|
});
|
||||||
|
let hue = (hash % 360) as f32 / 360.0;
|
||||||
|
let light = theme::palette().light;
|
||||||
|
let (saturation, value) = if light { (0.25, 0.92) } else { (0.45, 0.34) };
|
||||||
|
let from: Color32 = egui::ecolor::Hsva::new(hue, saturation, value, 1.0).into();
|
||||||
|
let to: Color32 =
|
||||||
|
egui::ecolor::Hsva::new((hue + 0.12) % 1.0, saturation, value * 0.65, 1.0).into();
|
||||||
|
theme::ramp_fill(
|
||||||
|
painter,
|
||||||
|
rect,
|
||||||
|
CornerRadius::same(theme::radius::MD),
|
||||||
|
(from, to),
|
||||||
|
theme::Direction::Vertical,
|
||||||
|
opacity,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ink = if light {
|
||||||
|
Color32::from_black_alpha(170)
|
||||||
|
} else {
|
||||||
|
Color32::from_white_alpha(215)
|
||||||
|
}
|
||||||
|
.gamma_multiply(opacity);
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
name.to_owned(),
|
||||||
|
theme::Role::Heading.font(),
|
||||||
|
ink,
|
||||||
|
rect.width() - 28.0,
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 3;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
job.halign = egui::Align::Center;
|
||||||
|
let galley = painter.layout_job(job);
|
||||||
|
painter.galley(
|
||||||
|
egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0),
|
||||||
|
galley,
|
||||||
|
ink,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a grid divides the width it was given.
|
/// How a grid divides the width it was given.
|
||||||
@@ -567,13 +834,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn initials_take_two_letters() {
|
|
||||||
assert_eq!(initials("Cowboy Bebop"), "CB");
|
|
||||||
assert_eq!(initials("Akira"), "A");
|
|
||||||
assert_eq!(initials(""), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn column_count_never_reaches_zero() {
|
fn column_count_never_reaches_zero() {
|
||||||
assert_eq!(columns(0.0).columns, 1);
|
assert_eq!(columns(0.0).columns, 1);
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const SKIP_PAD_X: f32 = 22.0;
|
|||||||
/// on the full wash. See [`scrim_bottom`].
|
/// on the full wash. See [`scrim_bottom`].
|
||||||
const SCRIM_ALPHA: f32 = 170.0;
|
const SCRIM_ALPHA: f32 = 170.0;
|
||||||
const SCRIM_FADE: f32 = 88.0;
|
const SCRIM_FADE: f32 = 88.0;
|
||||||
|
/// How wide the episode list is, at most. A narrow window gives it less.
|
||||||
|
const DRAWER_WIDTH: f32 = 380.0;
|
||||||
|
/// The stills in the episode list.
|
||||||
|
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
|
||||||
|
|
||||||
/// State the page keeps between frames.
|
/// State the page keeps between frames.
|
||||||
///
|
///
|
||||||
@@ -109,6 +113,17 @@ pub struct Chrome<'a> {
|
|||||||
pub up_next: Option<UpNextCard>,
|
pub up_next: Option<UpNextCard>,
|
||||||
/// Whether stepping to another episode is possible in either direction.
|
/// Whether stepping to another episode is possible in either direction.
|
||||||
pub neighbours: Neighbours,
|
pub neighbours: Neighbours,
|
||||||
|
/// The title's episodes, when it has more than one — what the list down
|
||||||
|
/// the right of the picture shows.
|
||||||
|
pub episodes: Option<Episodes<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The playing title's episodes, for the list beside the picture.
|
||||||
|
pub struct Episodes<'a> {
|
||||||
|
pub title: &'a pstr_core::library::Title,
|
||||||
|
pub art: ui::Art<'a>,
|
||||||
|
/// Whether the list is out.
|
||||||
|
pub open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the end-of-episode card says, when there is one.
|
/// What the end-of-episode card says, when there is one.
|
||||||
@@ -118,11 +133,21 @@ pub struct Chrome<'a> {
|
|||||||
pub struct UpNextCard {
|
pub struct UpNextCard {
|
||||||
/// Seconds until the next episode starts.
|
/// Seconds until the next episode starts.
|
||||||
pub seconds: f64,
|
pub seconds: f64,
|
||||||
|
/// The same, as a fraction of the whole countdown, for the ring.
|
||||||
|
pub left: f32,
|
||||||
/// Which episode that is.
|
/// Which episode that is.
|
||||||
pub caption: String,
|
pub caption: String,
|
||||||
|
/// Its still, once there is one.
|
||||||
|
pub still: Option<egui::TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
|
/// How much of the bottom of the window the controls take, the last time
|
||||||
|
/// they were drawn — so things floating over the picture can keep clear.
|
||||||
|
pub fn covered(&self) -> f32 {
|
||||||
|
self.chrome_height
|
||||||
|
}
|
||||||
|
|
||||||
/// Fold this frame's pointer into the overlay's timer.
|
/// Fold this frame's pointer into the overlay's timer.
|
||||||
pub fn observe(&mut self, ctx: &egui::Context) {
|
pub fn observe(&mut self, ctx: &egui::Context) {
|
||||||
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
|
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
|
||||||
@@ -177,6 +202,7 @@ pub fn show(
|
|||||||
overlay,
|
overlay,
|
||||||
up_next,
|
up_next,
|
||||||
neighbours,
|
neighbours,
|
||||||
|
mut episodes,
|
||||||
} = chrome_state;
|
} = chrome_state;
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
@@ -200,6 +226,8 @@ pub fn show(
|
|||||||
|
|
||||||
if !picture {
|
if !picture {
|
||||||
waiting(ui, playback, rect);
|
waiting(ui, playback, rect);
|
||||||
|
} else if playback.buffering || playback.seeking {
|
||||||
|
stalled(ui, rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click anywhere on the picture to pause, which is the one control that
|
// Click anywhere on the picture to pause, which is the one control that
|
||||||
@@ -208,10 +236,18 @@ pub fn show(
|
|||||||
actions.push(Action::Player(Command::TogglePause));
|
actions.push(Action::Player(Command::TogglePause));
|
||||||
}
|
}
|
||||||
if response.double_clicked() {
|
if response.double_clicked() {
|
||||||
|
// The first half of a double click already paused, and a viewer who
|
||||||
|
// double-clicked asked for fullscreen, not for a pause — so the second
|
||||||
|
// half takes the pause back. Waiting out the double-click delay before
|
||||||
|
// pausing would make every single click feel late instead.
|
||||||
|
actions.push(Action::Player(Command::TogglePause));
|
||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
let visible = overlay.visible(&ctx, playback);
|
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
|
||||||
|
// The controls stay up while the list is out: it hangs off the button in
|
||||||
|
// the title strip, and is read at leisure.
|
||||||
|
let visible = overlay.visible(&ctx, playback) || drawer_open;
|
||||||
// Above the chrome when it is up, at the bottom corner when it is not —
|
// Above the chrome when it is up, at the bottom corner when it is not —
|
||||||
// and clear of it either way, which is what the measured height buys.
|
// and clear of it either way, which is what the measured height buys.
|
||||||
let float_above = if visible {
|
let float_above = if visible {
|
||||||
@@ -226,16 +262,37 @@ pub fn show(
|
|||||||
None => skip(ui, playback, rect, float_above, actions),
|
None => skip(ui, playback, rect, float_above, actions),
|
||||||
}
|
}
|
||||||
|
|
||||||
if visible {
|
// Last frame's height, like everything else that has to clear the controls.
|
||||||
overlay.chrome_height = chrome(
|
playback.lift_subtitles(
|
||||||
ui,
|
if visible { overlay.chrome_height } else { 0.0 },
|
||||||
playback,
|
rect.height(),
|
||||||
neighbours,
|
);
|
||||||
rect,
|
|
||||||
overlay.chrome_height,
|
// Faded rather than switched, both ways: controls that blink out under a
|
||||||
actions,
|
// reading eye are more distracting than the ones that were there.
|
||||||
);
|
let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE);
|
||||||
|
if fade > 0.0 {
|
||||||
|
overlay.chrome_height = ui
|
||||||
|
.scope(|ui| {
|
||||||
|
ui.multiply_opacity(fade);
|
||||||
|
chrome(
|
||||||
|
ui,
|
||||||
|
playback,
|
||||||
|
neighbours,
|
||||||
|
episodes.as_ref().map(|list| list.open),
|
||||||
|
rect,
|
||||||
|
overlay.chrome_height,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
}
|
||||||
|
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
|
||||||
|
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
|
||||||
} else {
|
} else {
|
||||||
|
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||||
// The controls have to disappear on their own, and nothing else is
|
// The controls have to disappear on their own, and nothing else is
|
||||||
// going to cause a frame while a film plays without the mouse moving.
|
// going to cause a frame while a film plays without the mouse moving.
|
||||||
@@ -303,6 +360,18 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A spinner over a picture held waiting for data. Without it, a frozen frame
|
||||||
|
/// with the controls hidden looks the same as a hang.
|
||||||
|
fn stalled(ui: &mut egui::Ui, rect: Rect) {
|
||||||
|
let disc = Rect::from_center_size(rect.center(), Vec2::splat(64.0));
|
||||||
|
ui.painter()
|
||||||
|
.circle_filled(disc.center(), 32.0, Color32::from_black_alpha(140));
|
||||||
|
ui.put(
|
||||||
|
Rect::from_center_size(disc.center(), Vec2::splat(36.0)),
|
||||||
|
egui::Spinner::new().size(36.0).color(Color32::WHITE),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The screen between two episodes: one has ended and the next is opening.
|
/// The screen between two episodes: one has ended and the next is opening.
|
||||||
///
|
///
|
||||||
/// It keeps a way out, because opening a file over a public link can take
|
/// It keeps a way out, because opening a file over a public link can take
|
||||||
@@ -335,7 +404,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
|
|||||||
if ui.button("Back").clicked() {
|
if ui.button("Back").clicked() {
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -428,60 +497,114 @@ fn up_next_card(
|
|||||||
bottom_margin: f32,
|
bottom_margin: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
let size = Vec2::new(330.0, 104.0);
|
const STILL: Vec2 = Vec2::new(128.0, 72.0);
|
||||||
|
let size = Vec2::new(400.0, 104.0);
|
||||||
|
|
||||||
floating(ui, rect, size, bottom_margin, |ui| {
|
floating(ui, rect, size, bottom_margin, |ui| {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(Color32::from_black_alpha(215))
|
.fill(Color32::from_black_alpha(215))
|
||||||
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::symmetric(14, 12))
|
.inner_margin(egui::Margin::same(12))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(size.x - 28.0);
|
ui.set_width(size.x - 24.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(
|
// The still, with the countdown running round it: what is
|
||||||
theme::Role::Caption
|
// coming and when, read in one glance.
|
||||||
.rich("Up next")
|
let (frame, response) = ui.allocate_exact_size(STILL, Sense::click());
|
||||||
.strong()
|
let painter = ui.painter();
|
||||||
.color(Color32::from_white_alpha(200)),
|
let radius = CornerRadius::same(theme::radius::SM);
|
||||||
|
match &card.still {
|
||||||
|
Some(texture) => {
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(frame, radius, Color32::WHITE)
|
||||||
|
.with_texture(
|
||||||
|
texture.id(),
|
||||||
|
ui::cover_uv(texture.size_vec2(), frame.size()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
painter.rect_filled(frame, radius, Color32::from_white_alpha(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter.rect_filled(frame, radius, Color32::from_black_alpha(90));
|
||||||
|
countdown_ring(painter, frame.center(), 22.0, card.left);
|
||||||
|
// Rounded up, so a ring that says "1" is never followed by
|
||||||
|
// a second of nothing happening.
|
||||||
|
painter.text(
|
||||||
|
frame.center(),
|
||||||
|
Align2::CENTER_CENTER,
|
||||||
|
card.seconds.ceil() as u32,
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
if response.on_hover_text("Play now").clicked() {
|
||||||
// Rounded up, so a card that says "1s" is never
|
|
||||||
// followed by a second of nothing happening.
|
|
||||||
ui.label(
|
|
||||||
theme::Role::Caption
|
|
||||||
.rich(format!("in {}s", card.seconds.ceil() as u32))
|
|
||||||
.color(theme::accent()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add(
|
|
||||||
egui::Label::new(
|
|
||||||
theme::Role::Body
|
|
||||||
.rich(&card.caption)
|
|
||||||
.strong()
|
|
||||||
.color(Color32::WHITE),
|
|
||||||
)
|
|
||||||
.truncate(),
|
|
||||||
);
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui::accent_button(ui, "Play now").clicked() {
|
|
||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(6.0);
|
|
||||||
if ui
|
ui.add_space(theme::space::L);
|
||||||
.button("Watch till the end")
|
ui.vertical(|ui| {
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
ui.label(
|
||||||
.clicked()
|
theme::Role::Caption
|
||||||
{
|
.rich("Up next")
|
||||||
actions.push(Action::WatchToEnd);
|
.strong()
|
||||||
}
|
.color(Color32::from_white_alpha(200)),
|
||||||
|
);
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Body
|
||||||
|
.rich(&card.caption)
|
||||||
|
.strong()
|
||||||
|
.color(Color32::WHITE),
|
||||||
|
)
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui::accent_button(ui, "Play now").clicked() {
|
||||||
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
if ui
|
||||||
|
.button("Watch till the end")
|
||||||
|
.on_hover_text("Play this one out — nothing will be skipped")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::WatchToEnd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A ring that empties clockwise from twelve o'clock as `left` runs to zero.
|
||||||
|
fn countdown_ring(painter: &egui::Painter, center: egui::Pos2, radius: f32, left: f32) {
|
||||||
|
painter.circle_stroke(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(3.0, Color32::from_white_alpha(60)),
|
||||||
|
);
|
||||||
|
let left = left.clamp(0.0, 1.0);
|
||||||
|
if left <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let steps = (64.0 * left).ceil().max(2.0) as usize;
|
||||||
|
let sweep = std::f32::consts::TAU * left;
|
||||||
|
let points: Vec<egui::Pos2> = (0..=steps)
|
||||||
|
.map(|step| {
|
||||||
|
let angle = -std::f32::consts::FRAC_PI_2 + sweep * step as f32 / steps as f32;
|
||||||
|
center + radius * Vec2::new(angle.cos(), angle.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(egui::Shape::line(
|
||||||
|
points,
|
||||||
|
egui::Stroke::new(3.0, theme::accent()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
|
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
|
||||||
///
|
///
|
||||||
/// The inset matches the controls' own padding rather than being its own
|
/// The inset matches the controls' own padding rather than being its own
|
||||||
@@ -520,6 +643,7 @@ fn chrome(
|
|||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
|
drawer: Option<bool>,
|
||||||
rect: Rect,
|
rect: Rect,
|
||||||
previous_height: f32,
|
previous_height: f32,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
@@ -535,7 +659,7 @@ fn chrome(
|
|||||||
.add(
|
.add(
|
||||||
egui::Button::new(theme::Role::Label.rich("Library"))
|
egui::Button::new(theme::Role::Label.rich("Library"))
|
||||||
.fill(Color32::from_black_alpha(160))
|
.fill(Color32::from_black_alpha(160))
|
||||||
.corner_radius(CornerRadius::same(8))
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
.min_size(Vec2::new(96.0, 32.0)),
|
.min_size(Vec2::new(96.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
.on_hover_text("Keep playing, and go back to the library (Esc)")
|
||||||
@@ -543,7 +667,7 @@ fn chrome(
|
|||||||
{
|
{
|
||||||
actions.push(Action::LeavePlayer);
|
actions.push(Action::LeavePlayer);
|
||||||
}
|
}
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
theme::Role::Heading
|
theme::Role::Heading
|
||||||
@@ -559,18 +683,56 @@ fn chrome(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
|
||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(theme::Role::Subhead.rich("⛶"))
|
egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
|
||||||
.fill(Color32::from_black_alpha(160))
|
egui_phosphor::regular::CORNERS_IN
|
||||||
.corner_radius(CornerRadius::same(8))
|
} else {
|
||||||
.min_size(Vec2::new(38.0, 32.0)),
|
egui_phosphor::regular::CORNERS_OUT
|
||||||
|
}))
|
||||||
|
.fill(Color32::from_black_alpha(160))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Fullscreen (F)")
|
.on_hover_text(if fullscreen {
|
||||||
|
"Leave fullscreen (F)"
|
||||||
|
} else {
|
||||||
|
"Fullscreen (F)"
|
||||||
|
})
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
actions.push(Action::ToggleFullscreen);
|
actions.push(Action::ToggleFullscreen);
|
||||||
}
|
}
|
||||||
|
if let Some(open) = drawer {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
let fill = if open {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
Color32::from_black_alpha(160)
|
||||||
|
};
|
||||||
|
let ink = if open {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
Color32::WHITE
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::LIST_BULLETS)
|
||||||
|
.color(ink),
|
||||||
|
)
|
||||||
|
.fill(fill)
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.min_size(Vec2::new(38.0, 32.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Episodes (E)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -609,29 +771,225 @@ fn chrome(
|
|||||||
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
|
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The gradient behind the title strip, fading downwards out of the top edge.
|
/// Which file the episode list last scrolled to, in egui's memory, so it
|
||||||
///
|
/// scrolls to the playing one once when it opens rather than every frame.
|
||||||
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
|
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
|
||||||
/// primitive, and at this size the banding is invisible.
|
|
||||||
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
/// The title's episodes, down the right of the picture between the title
|
||||||
const BANDS: usize = 10;
|
/// strip and the controls. A season at a time, opened on the playing one.
|
||||||
let painter = ui.painter();
|
fn episode_drawer(
|
||||||
let band = rect.height() / BANDS as f32;
|
ctx: &egui::Context,
|
||||||
for index in 0..BANDS {
|
list: &mut Episodes<'_>,
|
||||||
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
|
playback: &Playback,
|
||||||
let strip = Rect::from_min_size(
|
rect: Rect,
|
||||||
egui::pos2(rect.left(), rect.top() + band * index as f32),
|
chrome_height: f32,
|
||||||
Vec2::new(rect.width(), band + 1.0),
|
actions: &mut Vec<Action>,
|
||||||
);
|
) {
|
||||||
painter.rect_filled(
|
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
|
||||||
strip,
|
let area = Rect::from_min_max(
|
||||||
CornerRadius::ZERO,
|
egui::pos2(rect.right() - width - 16.0, rect.top() + TITLE_HEIGHT),
|
||||||
// Squared, so the fade is dark where the text is and gone well
|
egui::pos2(rect.right() - 16.0, rect.bottom() - chrome_height - 8.0),
|
||||||
// before the middle of the picture — a linear ramp over a strip
|
);
|
||||||
// this tall greys the whole top third of the frame.
|
if area.height() < 160.0 {
|
||||||
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
let title = list.title;
|
||||||
|
let target = &playback.target;
|
||||||
|
let is_playing = |episode: &pstr_core::library::Episode| {
|
||||||
|
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
|
||||||
|
};
|
||||||
|
let playing_season = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.position(|season| season.episodes.iter().any(is_playing))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let season_id = egui::Id::new(("episode-drawer-season", &title.key));
|
||||||
|
let mut season = ctx
|
||||||
|
.data(|data| data.get_temp::<usize>(season_id))
|
||||||
|
.unwrap_or(playing_season)
|
||||||
|
.min(title.seasons.len().saturating_sub(1));
|
||||||
|
|
||||||
|
let margin = theme::space::L;
|
||||||
|
egui::Area::new(egui::Id::new("episode-drawer"))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(area.min)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface().gamma_multiply(0.96))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::same(margin as i8))
|
||||||
|
.shadow(theme::tile_shadow(1.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(area.width() - 2.0 * margin);
|
||||||
|
ui.set_height(area.height() - 2.0 * margin);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(egui_phosphor::regular::X)
|
||||||
|
.color(theme::muted()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.on_hover_text("Close (E)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ToggleEpisodes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if title.seasons.len() > 1 {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
let labels: Vec<(usize, String)> = title
|
||||||
|
.seasons
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, season)| (index, season.label()))
|
||||||
|
.collect();
|
||||||
|
let choices: Vec<(usize, &str)> = labels
|
||||||
|
.iter()
|
||||||
|
.map(|(index, label)| (*index, label.as_str()))
|
||||||
|
.collect();
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt("drawer-seasons")
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
|
||||||
|
season = picked;
|
||||||
|
ctx.data_mut(|data| data.insert_temp(season_id, picked));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
|
let Some(shown) = title.seasons.get(season) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("drawer-episodes")
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for episode in &shown.episodes {
|
||||||
|
let row = drawer_row(
|
||||||
|
ui,
|
||||||
|
&mut list.art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
is_playing(episode),
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
if is_playing(episode) {
|
||||||
|
let scrolled = ctx.data(|data| {
|
||||||
|
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
|
||||||
|
});
|
||||||
|
if scrolled.as_deref() != Some(target.link_id.as_str()) {
|
||||||
|
row.scroll_to_me(Some(egui::Align::Center));
|
||||||
|
ctx.data_mut(|data| {
|
||||||
|
data.insert_temp(
|
||||||
|
egui::Id::new(DRAWER_SCROLLED),
|
||||||
|
target.link_id.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode in the list: its still, which plays it, and what it is.
|
||||||
|
fn drawer_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut ui::Art<'_>,
|
||||||
|
title: &pstr_core::library::Title,
|
||||||
|
episode: &pstr_core::library::Episode,
|
||||||
|
playing: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) -> egui::Response {
|
||||||
|
let frame = egui::Frame::new()
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(6))
|
||||||
|
.fill(if playing {
|
||||||
|
theme::accent_dim()
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
});
|
||||||
|
let response = frame
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui::title::still(
|
||||||
|
ui,
|
||||||
|
art,
|
||||||
|
title,
|
||||||
|
episode,
|
||||||
|
episode.is_watched(),
|
||||||
|
DRAWER_STILL,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = theme::space::XXS;
|
||||||
|
let name = art
|
||||||
|
.episode(&title.key, episode)
|
||||||
|
.and_then(|found| found.name.clone())
|
||||||
|
.unwrap_or_else(|| episode.detail().to_owned());
|
||||||
|
if let Some(numbering) = episode.numbering() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(numbering)
|
||||||
|
.monospace()
|
||||||
|
.color(theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
name,
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
if episode.is_watched() && !playing {
|
||||||
|
theme::muted()
|
||||||
|
} else {
|
||||||
|
theme::text()
|
||||||
|
},
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
job.wrap.max_rows = 2;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
ui.label(job);
|
||||||
|
let state = if playing {
|
||||||
|
Some("Playing".to_owned())
|
||||||
|
} else if episode.is_watched() {
|
||||||
|
Some("Watched".to_owned())
|
||||||
|
} else {
|
||||||
|
episode
|
||||||
|
.resume_at()
|
||||||
|
.map(|at| format!("Stopped at {}", ui::format_time(at)))
|
||||||
|
};
|
||||||
|
if let Some(state) = state {
|
||||||
|
ui.label(theme::Role::Caption.rich(state).color(if playing {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gradient behind the title strip, fading downwards out of the top edge.
|
||||||
|
fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
||||||
|
// Squared, so the fade is dark where the text is and gone well before the
|
||||||
|
// middle of the picture — a linear ramp over a strip this tall greys the
|
||||||
|
// whole top third of the frame.
|
||||||
|
fade(ui.painter(), rect, |t| {
|
||||||
|
let fraction = 1.0 - t;
|
||||||
|
fraction * fraction * 225.0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wash behind the controls: flat over the whole strip, fading in above it.
|
/// The wash behind the controls: flat over the whole strip, fading in above it.
|
||||||
@@ -642,27 +1000,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) {
|
|||||||
/// strip itself is one flat wash, and the ramp happens in the empty picture
|
/// strip itself is one flat wash, and the ramp happens in the empty picture
|
||||||
/// above it, where there is nothing to obscure.
|
/// above it, where there is nothing to obscure.
|
||||||
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
|
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
|
||||||
const BANDS: usize = 12;
|
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
strip,
|
strip,
|
||||||
CornerRadius::ZERO,
|
CornerRadius::ZERO,
|
||||||
Color32::from_black_alpha(SCRIM_ALPHA as u8),
|
Color32::from_black_alpha(SCRIM_ALPHA as u8),
|
||||||
);
|
);
|
||||||
|
let above = Rect::from_min_max(
|
||||||
|
egui::pos2(strip.left(), strip.top() - SCRIM_FADE),
|
||||||
|
egui::pos2(strip.right(), strip.top()),
|
||||||
|
);
|
||||||
|
// Squared again: the wash should meet the strip at full strength and be
|
||||||
|
// gone within a finger's width of picture above it.
|
||||||
|
fade(painter, above, |t| t * t * SCRIM_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
let band = SCRIM_FADE / BANDS as f32;
|
/// Black over `rect`, at the alpha `curve` gives for each fraction of the way
|
||||||
for index in 0..BANDS {
|
/// down it.
|
||||||
let fraction = (index + 1) as f32 / BANDS as f32;
|
///
|
||||||
let rect = Rect::from_min_size(
|
/// One mesh with a colour per row of vertices, which the GPU interpolates
|
||||||
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
|
/// between. It replaced a stack of flat bands, and the bands were what showed:
|
||||||
Vec2::new(strip.width(), band + 1.0),
|
/// each overlapped the next by a pixel so no gap could open between them, and
|
||||||
);
|
/// every overlap was a line of double darkness across the picture.
|
||||||
painter.rect_filled(
|
fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
|
||||||
rect,
|
/// Enough rows that the curve between them reads as a curve.
|
||||||
CornerRadius::ZERO,
|
const ROWS: u32 = 16;
|
||||||
// Squared again: the wash should meet the strip at full strength
|
let mut mesh = egui::Mesh::default();
|
||||||
// and be gone within a finger's width of picture above it.
|
for row in 0..=ROWS {
|
||||||
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
|
let t = row as f32 / ROWS as f32;
|
||||||
);
|
let y = egui::lerp(rect.top()..=rect.bottom(), t);
|
||||||
|
let colour = Color32::from_black_alpha(curve(t).clamp(0.0, 255.0) as u8);
|
||||||
|
mesh.colored_vertex(egui::pos2(rect.left(), y), colour);
|
||||||
|
mesh.colored_vertex(egui::pos2(rect.right(), y), colour);
|
||||||
|
if row > 0 {
|
||||||
|
let base = (row - 1) * 2;
|
||||||
|
mesh.add_triangle(base, base + 1, base + 2);
|
||||||
|
mesh.add_triangle(base + 1, base + 3, base + 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
painter.add(egui::Shape::mesh(mesh));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a key just did, in a pill near the top of the picture, for a moment.
|
||||||
|
///
|
||||||
|
/// Returns whether it is still showing.
|
||||||
|
pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool {
|
||||||
|
/// How long it stays, and how much of that is fading out.
|
||||||
|
const SECONDS: f64 = 0.9;
|
||||||
|
const FADE: f64 = 0.25;
|
||||||
|
|
||||||
|
let age = ctx.input(|input| input.time) - at;
|
||||||
|
if age >= SECONDS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32;
|
||||||
|
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||||
|
egui::Order::Foreground,
|
||||||
|
egui::Id::new("osd"),
|
||||||
|
));
|
||||||
|
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE);
|
||||||
|
let size = galley.size() + Vec2::new(36.0, 18.0);
|
||||||
|
let screen = ctx.content_rect();
|
||||||
|
let frame = Rect::from_center_size(
|
||||||
|
egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
frame,
|
||||||
|
CornerRadius::same((size.y / 2.0) as u8),
|
||||||
|
Color32::from_black_alpha((190.0 * opacity) as u8),
|
||||||
|
);
|
||||||
|
painter.galley_with_override_text_color(
|
||||||
|
frame.min + Vec2::new(18.0, 9.0),
|
||||||
|
galley,
|
||||||
|
Color32::WHITE.gamma_multiply(opacity),
|
||||||
|
);
|
||||||
|
ctx.request_repaint();
|
||||||
|
true
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ use pstr_core::appearance::{Accent, Appearance, Flavor};
|
|||||||
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
use pstr_core::metadata::{MetadataConfig, ProviderId};
|
||||||
|
|
||||||
use crate::app::Action;
|
use crate::app::Action;
|
||||||
|
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
@@ -20,9 +21,17 @@ use crate::ui;
|
|||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Prefs {
|
pub struct Prefs {
|
||||||
pub autoplay: bool,
|
pub autoplay: bool,
|
||||||
|
pub auto_skip: bool,
|
||||||
pub appearance: Appearance,
|
pub appearance: Appearance,
|
||||||
|
pub desktop: DesktopPrefs,
|
||||||
|
/// What the streaming cache holds, once the shares are open.
|
||||||
|
pub cache_used: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The widest the settings column gets. Rows that run the width of a 2K window
|
||||||
|
/// put a switch a whole screen away from what it switches.
|
||||||
|
const COLUMN: f32 = 760.0;
|
||||||
|
|
||||||
pub fn show(
|
pub fn show(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
settings: &mut MetadataConfig,
|
settings: &mut MetadataConfig,
|
||||||
@@ -33,13 +42,22 @@ pub fn show(
|
|||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
appearance_form(ui, prefs.appearance, actions);
|
// Centred, so a wide window frames the column rather than leaving it
|
||||||
|
// against one edge with the rest of the page empty.
|
||||||
ui.add_space(28.0);
|
let width = ui.available_width().min(COLUMN);
|
||||||
playback_form(ui, prefs.autoplay, actions);
|
let margin = ((ui.available_width() - width) / 2.0).max(0.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.add_space(28.0);
|
ui.add_space(margin);
|
||||||
metadata_form(ui, settings, api_key, actions);
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(width);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
appearance_form(ui, prefs.appearance, actions);
|
||||||
|
playback_form(ui, prefs, actions);
|
||||||
|
storage_form(ui, prefs, actions);
|
||||||
|
metadata_form(ui, settings, api_key, actions);
|
||||||
|
about(ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +67,14 @@ pub fn show(
|
|||||||
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
/// there is no "apply" — because the preview *is* the app. A swatch row that
|
||||||
/// only changed a small square would be asking someone to imagine the result.
|
/// only changed a small square would be asking someone to imagine the result.
|
||||||
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Appearance");
|
ui::widgets::settings_group(ui, "Appearance", None, |ui| {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
egui::Frame::new()
|
ui.label(theme::Role::Body.rich("Palette").color(theme::text()));
|
||||||
.fill(theme::card())
|
ui.add_space(theme::space::M);
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0);
|
||||||
.inner_margin(egui::Margin::same(14))
|
ui.horizontal_wrapped(|ui| {
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
|
||||||
|
|
||||||
ui.label(ui::muted("Palette"));
|
|
||||||
for flavor in Flavor::ALL {
|
for flavor in Flavor::ALL {
|
||||||
if ui
|
if palette_card(ui, appearance, flavor)
|
||||||
.radio(appearance.flavor == flavor, flavor.label())
|
|
||||||
.on_hover_text(flavor.description())
|
.on_hover_text(flavor.description())
|
||||||
.clicked()
|
.clicked()
|
||||||
&& appearance.flavor != flavor
|
&& appearance.flavor != flavor
|
||||||
@@ -72,35 +85,123 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui::widgets::settings_row(ui, "Accent", None, |ui| {
|
||||||
ui.label(ui::muted("Accent"));
|
ui.spacing_mut().item_spacing.x = 8.0;
|
||||||
ui.horizontal_wrapped(|ui| {
|
// Right to left, so reversed to read in the order they are listed.
|
||||||
for accent in Accent::ALL {
|
for accent in Accent::ALL.into_iter().rev() {
|
||||||
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
if swatch(ui, appearance, accent).clicked() && appearance.accent != accent {
|
||||||
actions.push(Action::SetAppearance(Appearance {
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
accent,
|
accent,
|
||||||
..appearance
|
..appearance
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
let mut gradients = appearance.gradients;
|
|
||||||
if ui
|
|
||||||
.checkbox(&mut gradients, "Fade the accent into a second colour")
|
|
||||||
.on_hover_text(
|
|
||||||
"Off draws it flat — better on a panel that bands a gradient into stripes",
|
|
||||||
)
|
|
||||||
.changed()
|
|
||||||
{
|
|
||||||
actions.push(Action::SetAppearance(Appearance {
|
|
||||||
gradients,
|
|
||||||
..appearance
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut gradients = appearance.gradients;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Gradient accent",
|
||||||
|
Some(
|
||||||
|
"Fade the accent into a second colour. Off draws it flat, which suits a panel that bands gradients into stripes.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut gradients, "").changed() {
|
||||||
|
actions.push(Action::SetAppearance(Appearance {
|
||||||
|
gradients,
|
||||||
|
..appearance
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One palette, as a miniature of the app wearing it: page, a card on it, a
|
||||||
|
/// line of text and the accent.
|
||||||
|
fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> egui::Response {
|
||||||
|
let size = egui::vec2(132.0, 92.0);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let palette = theme::Palette::resolve(Appearance {
|
||||||
|
flavor,
|
||||||
|
..appearance
|
||||||
|
});
|
||||||
|
let selected = appearance.flavor == flavor;
|
||||||
|
let hover =
|
||||||
|
ui.ctx()
|
||||||
|
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
|
let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0));
|
||||||
|
|
||||||
|
painter.rect_filled(preview, radius, palette.background);
|
||||||
|
let bar = egui::Rect::from_min_size(preview.min, egui::vec2(size.x, 12.0));
|
||||||
|
painter.rect_filled(
|
||||||
|
bar,
|
||||||
|
egui::CornerRadius {
|
||||||
|
nw: 8,
|
||||||
|
ne: 8,
|
||||||
|
sw: 0,
|
||||||
|
se: 0,
|
||||||
|
},
|
||||||
|
palette.surface,
|
||||||
|
);
|
||||||
|
let tile = |x: f32| {
|
||||||
|
egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0))
|
||||||
|
};
|
||||||
|
for x in [8.0, 49.0, 90.0] {
|
||||||
|
painter.rect_filled(
|
||||||
|
tile(x),
|
||||||
|
egui::CornerRadius::same(theme::radius::BAR),
|
||||||
|
palette.card,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pill =
|
||||||
|
egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0));
|
||||||
|
theme::swatch_fill(
|
||||||
|
painter,
|
||||||
|
pill,
|
||||||
|
egui::CornerRadius::same(theme::radius::BAR),
|
||||||
|
&palette,
|
||||||
|
);
|
||||||
|
painter.hline(
|
||||||
|
(preview.left() + 8.0)..=(preview.left() + 60.0),
|
||||||
|
preview.top() + 50.0,
|
||||||
|
egui::Stroke::new(2.0, palette.text),
|
||||||
|
);
|
||||||
|
painter.hline(
|
||||||
|
(preview.left() + 8.0)..=(preview.left() + 40.0),
|
||||||
|
preview.top() + 56.0,
|
||||||
|
egui::Stroke::new(2.0, palette.muted),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ring = if selected {
|
||||||
|
egui::Stroke::new(2.0, theme::accent())
|
||||||
|
} else {
|
||||||
|
egui::Stroke::new(
|
||||||
|
1.0,
|
||||||
|
theme::card_hover().lerp_to_gamma(theme::muted(), hover),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.rect_stroke(preview, radius, ring, egui::StrokeKind::Outside);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.left() + 2.0, preview.bottom() + 8.0),
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
flavor.label(),
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
if selected {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One accent, drawn in the colours it would actually produce.
|
/// One accent, drawn in the colours it would actually produce.
|
||||||
@@ -109,23 +210,22 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec<
|
|||||||
/// the row previews what clicking it would give rather than what is on screen.
|
/// the row previews what clicking it would give rather than what is on screen.
|
||||||
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response {
|
||||||
let selected = appearance.accent == accent;
|
let selected = appearance.accent == accent;
|
||||||
let size = egui::Vec2::new(52.0, 26.0);
|
let size = egui::Vec2::splat(26.0);
|
||||||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let palette = theme::Palette::resolve(Appearance {
|
let palette = theme::Palette::resolve(Appearance {
|
||||||
accent,
|
accent,
|
||||||
..appearance
|
..appearance
|
||||||
});
|
});
|
||||||
let radius = egui::CornerRadius::same(6);
|
let radius = egui::CornerRadius::same(13);
|
||||||
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
theme::swatch_fill(ui.painter(), rect, radius, &palette);
|
||||||
if selected {
|
if selected {
|
||||||
// A ring rather than a tick: the tick would have to be drawn in an
|
// A ring rather than a tick: the tick would have to be drawn in an
|
||||||
// ink that reads on eight different fills.
|
// ink that reads on eight different fills.
|
||||||
ui.painter().rect_stroke(
|
ui.painter().circle_stroke(
|
||||||
rect.expand(2.0),
|
rect.center(),
|
||||||
egui::CornerRadius::same(8),
|
rect.width() / 2.0 + 3.0,
|
||||||
egui::Stroke::new(2.0, theme::text()),
|
egui::Stroke::new(2.0, theme::text()),
|
||||||
egui::StrokeKind::Outside,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,34 +234,120 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re
|
|||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The handful of playback settings that are not per-file.
|
/// The playback settings that are not per-file.
|
||||||
///
|
///
|
||||||
/// Volume, audio language and subtitle language are all set from the player
|
/// Volume, audio language, subtitle language and speed are all set from the
|
||||||
/// itself, where a viewer can hear the result — only autoplay has no natural
|
/// player itself, where a viewer can hear the result.
|
||||||
/// home there, because by the time it matters the episode is over.
|
fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||||
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
|
ui::widgets::settings_group(
|
||||||
ui::section(ui, "Playback");
|
ui,
|
||||||
|
"Playback",
|
||||||
|
Some(
|
||||||
|
"Volume, audio, subtitles and speed are set from the player, and remembered from there.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let mut autoplay = prefs.autoplay;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Play the next episode",
|
||||||
|
Some("When an episode ends, start the next one. Never after a file that failed."),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut autoplay, "").changed() {
|
||||||
|
actions.push(Action::SetAutoplay(autoplay));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut auto_skip = prefs.auto_skip;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Skip openings and credits",
|
||||||
|
Some(
|
||||||
|
"Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut auto_skip, "").changed() {
|
||||||
|
actions.push(Action::SetAutoSkip(auto_skip));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut desktop = prefs.desktop;
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Hardware decoding",
|
||||||
|
Some(
|
||||||
|
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
|
||||||
|
actions.push(Action::SetDesktopPrefs(desktop));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
egui::Frame::new()
|
/// The streaming cache: how big it may get, and a way to empty it.
|
||||||
.fill(theme::card())
|
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
ui::widgets::settings_group(
|
||||||
.inner_margin(egui::Margin::same(14))
|
ui,
|
||||||
.show(ui, |ui| {
|
"Storage",
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
Some(
|
||||||
|
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
|
||||||
|
let used = match prefs.cache_used {
|
||||||
|
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
|
||||||
|
None => format!("Up to {budget}"),
|
||||||
|
};
|
||||||
|
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
|
||||||
|
let choices: Vec<(u32, String)> = CACHE_CHOICES
|
||||||
|
.iter()
|
||||||
|
.map(|&gib| (gib, format!("{gib} GiB")))
|
||||||
|
.collect();
|
||||||
|
let labels: Vec<(u32, &str)> = choices
|
||||||
|
.iter()
|
||||||
|
.map(|(gib, label)| (*gib, label.as_str()))
|
||||||
|
.collect();
|
||||||
|
if let Some(cache_gib) =
|
||||||
|
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
|
||||||
|
{
|
||||||
|
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
|
||||||
|
cache_gib,
|
||||||
|
..prefs.desktop
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui::widgets::settings_row(
|
||||||
|
ui,
|
||||||
|
"Clear the streaming cache",
|
||||||
|
Some("Frees the space now. Whatever plays next is fetched again."),
|
||||||
|
|ui| {
|
||||||
|
if ui.button("Clear").clicked() {
|
||||||
|
actions.push(Action::ClearStreamCache);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut enabled = autoplay;
|
/// What this is, and which version.
|
||||||
if ui
|
fn about(ui: &mut egui::Ui) {
|
||||||
.checkbox(&mut enabled, "Play the next episode automatically")
|
ui::widgets::settings_group(ui, "About", None, |ui| {
|
||||||
.on_hover_text("Only when an episode reaches its end, never after a failure")
|
ui::widgets::settings_row(
|
||||||
.changed()
|
ui,
|
||||||
{
|
"proton-stream",
|
||||||
actions.push(Action::SetAutoplay(enabled));
|
Some("Streams Proton Drive share links without an account or a download step."),
|
||||||
}
|
|ui| {
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION"))));
|
||||||
"Volume, audio language and subtitles are set from the player, and remembered \
|
},
|
||||||
from there.",
|
);
|
||||||
));
|
ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| {
|
||||||
|
ui.label(ui::muted("press ?"));
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
/// Enrichment: the switch, the provider, and the cost of turning it on.
|
||||||
@@ -176,94 +362,86 @@ fn metadata_form(
|
|||||||
api_key: &mut String,
|
api_key: &mut String,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
ui::section(ui, "Posters and descriptions");
|
ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
egui::Frame::new()
|
ui.spacing_mut().item_spacing.y = 6.0;
|
||||||
.fill(theme::card())
|
ui.label(ui::muted(
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
||||||
.inner_margin(egui::Margin::same(14))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
|
||||||
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
|
|
||||||
without this every tile is a pair of initials. Turning it on sends the titles \
|
without this every tile is a pair of initials. Turning it on sends the titles \
|
||||||
in your library — not your files, and nothing about what you have watched — to \
|
in your library — not your files, and nothing about what you have watched — to \
|
||||||
the provider you choose, over HTTPS, each time a new one appears.",
|
the provider you choose, over HTTPS, each time a new one appears.",
|
||||||
));
|
));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut enabled = settings.enabled;
|
let mut enabled = settings.enabled;
|
||||||
if ui
|
if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() {
|
||||||
.checkbox(&mut enabled, "Look up posters and descriptions")
|
settings.enabled = enabled;
|
||||||
.changed()
|
changed = true;
|
||||||
{
|
}
|
||||||
settings.enabled = enabled;
|
|
||||||
changed = true;
|
if settings.enabled {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(ui::muted("Provider"));
|
||||||
|
for provider in ProviderId::ALL {
|
||||||
|
if ui
|
||||||
|
.radio(settings.provider == provider, provider.label())
|
||||||
|
.on_hover_text(provider.description())
|
||||||
|
.clicked()
|
||||||
|
&& settings.provider != provider
|
||||||
|
{
|
||||||
|
settings.provider = provider;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.enabled {
|
if settings.provider.needs_api_key() {
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
ui.label(ui::muted("Provider"));
|
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||||
for provider in ProviderId::ALL {
|
|
||||||
if ui
|
|
||||||
.radio(settings.provider == provider, provider.label())
|
|
||||||
.on_hover_text(provider.description())
|
|
||||||
.clicked()
|
|
||||||
&& settings.provider != provider
|
|
||||||
{
|
|
||||||
settings.provider = provider;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.provider.needs_api_key() {
|
|
||||||
ui.add_space(10.0);
|
|
||||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.add(
|
|
||||||
egui::TextEdit::singleline(api_key)
|
|
||||||
.password(true)
|
|
||||||
.hint_text("v3 API key")
|
|
||||||
.desired_width(300.0),
|
|
||||||
);
|
|
||||||
if ui.button("Save key").clicked() {
|
|
||||||
actions.push(Action::SetApiKey {
|
|
||||||
provider: settings.provider,
|
|
||||||
key: std::mem::take(api_key),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.label(ui::muted(
|
|
||||||
"Stored in your system keyring, never in a config file. Leave the box \
|
|
||||||
empty and save to forget it.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui::accent_button(ui, "Match the library").clicked() {
|
ui.add(
|
||||||
actions.push(Action::MatchTitles { force: false });
|
egui::TextEdit::singleline(api_key)
|
||||||
}
|
.password(true)
|
||||||
if ui
|
.hint_text("v3 API key")
|
||||||
.button("Match everything again")
|
.desired_width(300.0),
|
||||||
.on_hover_text("Ask about every title, including ones already matched")
|
);
|
||||||
.clicked()
|
if ui.button("Save key").clicked() {
|
||||||
{
|
actions.push(Action::SetApiKey {
|
||||||
actions.push(Action::MatchTitles { force: true });
|
provider: settings.provider,
|
||||||
|
key: std::mem::take(api_key),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
"Stored in your system keyring, never in a config file. Leave the box \
|
||||||
already stored.",
|
empty and save to forget it.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
ui.add_space(theme::space::L);
|
||||||
actions.push(Action::SetMetadataConfig(settings.clone()));
|
ui.horizontal(|ui| {
|
||||||
}
|
if ui::accent_button(ui, "Match the library").clicked() {
|
||||||
});
|
actions.push(Action::MatchTitles { force: false });
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button("Match everything again")
|
||||||
|
.on_hover_text("Ask about every title, including ones already matched")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MatchTitles { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.label(ui::muted(
|
||||||
|
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
|
||||||
|
already stored.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
actions.push(Action::SetMetadataConfig(settings.clone()));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -7,44 +7,59 @@
|
|||||||
//! typed.
|
//! typed.
|
||||||
|
|
||||||
use pstr_core::Share;
|
use pstr_core::Share;
|
||||||
|
use pstr_core::library::Library;
|
||||||
|
|
||||||
use crate::app::{Action, ShareForm};
|
use crate::app::{Action, ShareForm};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
pub fn show(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
shares: &[Share],
|
||||||
|
library: &Library,
|
||||||
|
form: &mut ShareForm,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui::section(ui, "Shares");
|
ui::section(ui, "Shares");
|
||||||
if shares.is_empty() {
|
if shares.is_empty() {
|
||||||
ui.label(ui::muted("No shares yet."));
|
ui.label(ui::muted(
|
||||||
|
"No shares yet. Add one below and its contents become your library.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
for share in shares {
|
for share in shares {
|
||||||
share_row(ui, share, actions);
|
let titles = library
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.filter(|title| title.share_ids.contains(&share.id))
|
||||||
|
.count();
|
||||||
|
share_row(ui, share, titles, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(22.0);
|
ui.add_space(theme::space::XXL);
|
||||||
add_form(ui, form, actions);
|
add_form(ui, shares, form, actions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<Action>) {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::symmetric(12, 10))
|
.inner_margin(egui::Margin::symmetric(12, 10))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(egui::RichText::new(&share.name).strong());
|
ui.label(egui::RichText::new(&share.name).strong());
|
||||||
let detail = if share.has_custom_password {
|
// What is in it, rather than what it is called inside the
|
||||||
format!("{} · custom password", share.id)
|
// app. The id is still a hover away for a bug report.
|
||||||
} else {
|
let mut detail = ui::library::plural(titles, "title");
|
||||||
share.id.clone()
|
if share.has_custom_password {
|
||||||
};
|
detail.push_str(" · password protected");
|
||||||
ui.label(ui::muted(detail));
|
}
|
||||||
|
ui.label(ui::muted(detail)).on_hover_text(&share.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
@@ -61,20 +76,20 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>) {
|
fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||||
ui::section(ui, "Add a share");
|
ui::section(ui, "Add a share");
|
||||||
ui.label(ui::muted(
|
ui.label(ui::muted(
|
||||||
"Paste the whole link, including everything after the # — that part is the key that \
|
"Paste the whole link, including everything after the # — that part is the key that \
|
||||||
decrypts it, and it is stored in your system keyring rather than on disk.",
|
decrypts it, and it is stored in your system keyring rather than on disk.",
|
||||||
));
|
));
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(theme::card())
|
.fill(theme::card())
|
||||||
.corner_radius(egui::CornerRadius::same(8))
|
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
|
||||||
.inner_margin(egui::Margin::same(14))
|
.inner_margin(egui::Margin::same(14))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width().min(640.0));
|
ui.set_width(ui.available_width().min(640.0));
|
||||||
@@ -85,20 +100,45 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
.hint_text("Anime")
|
.hint_text("Anime")
|
||||||
.desired_width(f32::INFINITY),
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui.label(ui::muted("Link"));
|
ui.label(ui::muted("Link"));
|
||||||
ui.add(
|
ui.horizontal(|ui| {
|
||||||
egui::TextEdit::singleline(&mut form.url)
|
let paste = 60.0;
|
||||||
.password(true)
|
let field = egui::Id::new("share-link");
|
||||||
.hint_text("https://drive.proton.me/urls/…#…")
|
ui.add(
|
||||||
.desired_width(f32::INFINITY),
|
egui::TextEdit::singleline(&mut form.url)
|
||||||
);
|
.id(field)
|
||||||
ui.add_space(8.0);
|
.password(true)
|
||||||
|
.hint_text("https://drive.proton.me/urls/…#…")
|
||||||
|
.desired_width(ui.available_width() - paste),
|
||||||
|
);
|
||||||
|
// The link is masked, so pasting is the only sensible way in —
|
||||||
|
// and a button says that more plainly than the field does.
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
[paste - 8.0, ui.spacing().interact_size.y],
|
||||||
|
egui::Button::new("Paste"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
ui.ctx()
|
||||||
|
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
|
||||||
|
// The paste lands in whatever has focus, so give it some.
|
||||||
|
ui.memory_mut(|memory| memory.request_focus(field));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Said while typing, not after a refused submit: everything this
|
||||||
|
// checks is knowable from the text alone.
|
||||||
|
let problem = link_problem(&form.url, shares);
|
||||||
|
if let Some(problem) = &problem {
|
||||||
|
ui.label(theme::Role::Caption.rich(problem).color(theme::danger()));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
|
||||||
ui.checkbox(&mut form.has_password, "The link asks for a password");
|
ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password");
|
||||||
if form.has_password {
|
if form.has_password {
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::TextEdit::singleline(&mut form.password)
|
egui::TextEdit::singleline(&mut form.password)
|
||||||
.password(true)
|
.password(true)
|
||||||
@@ -107,11 +147,17 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
let ready = !form.name.trim().is_empty()
|
let ready = !form.name.trim().is_empty()
|
||||||
&& !form.url.trim().is_empty()
|
&& !form.url.trim().is_empty()
|
||||||
&& (!form.has_password || !form.password.is_empty());
|
&& problem.is_none()
|
||||||
|
&& (!form.has_password || !form.password.is_empty())
|
||||||
|
&& !form.sending;
|
||||||
|
|
||||||
|
if let Some(error) = &form.error {
|
||||||
|
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
if ui
|
||||||
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
|
||||||
@@ -127,9 +173,70 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
|
|||||||
.filter(|password| !password.is_empty()),
|
.filter(|password| !password.is_empty()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !ready {
|
if form.sending {
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.label(ui::muted("adding…"));
|
||||||
|
} else if form.name.trim().is_empty() || form.url.trim().is_empty() {
|
||||||
ui.label(ui::muted("A name and a link are needed."));
|
ui.label(ui::muted("A name and a link are needed."));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What is wrong with a link as typed, if anything.
|
||||||
|
///
|
||||||
|
/// Nothing for an empty box — that is "not done yet", which the button already
|
||||||
|
/// says.
|
||||||
|
fn link_problem(url: &str, shares: &[Share]) -> Option<String> {
|
||||||
|
let url = url.trim();
|
||||||
|
if url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match pstr_core::shares::share_token(url) {
|
||||||
|
Ok(token) if shares.iter().any(|share| share.token == token) => {
|
||||||
|
Some("That share is already added.".into())
|
||||||
|
}
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(error) => Some(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn share(token: &str) -> Share {
|
||||||
|
Share {
|
||||||
|
id: format!("share-{token}"),
|
||||||
|
name: "anime".into(),
|
||||||
|
token: token.into(),
|
||||||
|
has_custom_password: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_link_is_not_yet_a_problem() {
|
||||||
|
assert_eq!(link_problem(" ", &[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_link_without_its_key_is_refused_while_typing() {
|
||||||
|
assert!(link_problem("https://drive.proton.me/urls/ABC123", &[]).is_some());
|
||||||
|
assert!(link_problem("https://example.com/", &[]).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
link_problem("https://drive.proton.me/urls/ABC123#key", &[]),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_already_added_is_refused_while_typing() {
|
||||||
|
assert!(
|
||||||
|
link_problem(
|
||||||
|
"https://drive.proton.me/urls/ABC123#key",
|
||||||
|
&[share("ABC123")]
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! The list of keyboard shortcuts, on `?`.
|
||||||
|
//!
|
||||||
|
//! Written out here rather than generated from the handlers, because the
|
||||||
|
//! handlers are split between the player page and every other page, and what a
|
||||||
|
//! viewer needs is one list grouped by where they are.
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
||||||
|
(
|
||||||
|
"Anywhere",
|
||||||
|
&[
|
||||||
|
("Ctrl F or /", "Search the library"),
|
||||||
|
("F5 or Ctrl R", "Crawl every share again"),
|
||||||
|
(
|
||||||
|
"Ctrl 1 – 5",
|
||||||
|
"Library, History, Shares, Downloads, Settings",
|
||||||
|
),
|
||||||
|
("Ctrl ,", "Settings"),
|
||||||
|
("Ctrl V", "Paste a share link to add it"),
|
||||||
|
("Tab or arrows", "Move between titles and episodes"),
|
||||||
|
("Enter", "Open or play what is highlighted"),
|
||||||
|
("Alt ← or mouse back", "Back"),
|
||||||
|
("?", "This list"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Playing",
|
||||||
|
&[
|
||||||
|
("Space or K", "Pause and play"),
|
||||||
|
("← / →", "Back 10 s, forward 30 s"),
|
||||||
|
("↑ / ↓", "Volume"),
|
||||||
|
("Ctrl S", "Save the frame to Pictures/proton-stream"),
|
||||||
|
("M", "Mute"),
|
||||||
|
("N / P", "Next and previous episode"),
|
||||||
|
("[ / ]", "Slower and faster"),
|
||||||
|
("S", "Skip the opening or the credits"),
|
||||||
|
("E", "The list of episodes"),
|
||||||
|
("F or double-click", "Fullscreen"),
|
||||||
|
("Esc", "Leave fullscreen, then the player"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Draw the list. Returns whether it asked to be closed.
|
||||||
|
pub fn show(ctx: &egui::Context) -> bool {
|
||||||
|
let mut close = false;
|
||||||
|
let modal = egui::Modal::new(egui::Id::new("shortcuts"))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.inner_margin(egui::Margin::same(20))
|
||||||
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG)),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(460.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(theme::Role::Heading.rich("Keyboard shortcuts").strong());
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (heading, keys) in GROUPS {
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
ui.label(ui::muted(heading.to_uppercase()));
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
egui::Grid::new(*heading)
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([18.0, 8.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
for (key, what) in *keys {
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Label
|
||||||
|
.rich(*key)
|
||||||
|
.monospace()
|
||||||
|
.color(theme::accent()),
|
||||||
|
);
|
||||||
|
ui.label(theme::Role::Body.rich(*what));
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
close || modal.should_close()
|
||||||
|
}
|
||||||
@@ -30,65 +30,153 @@ pub fn show(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if ui.button("Library").clicked() {
|
if back_link(ui).clicked() {
|
||||||
actions.push(Action::Goto(Page::Library));
|
actions.push(Action::Goto(Page::Library));
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
header(ui, art, title, &offline, actions);
|
header(ui, art, title, &offline, actions);
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XL);
|
||||||
|
|
||||||
for (index, season) in title.seasons.iter().enumerate() {
|
// One season at a time, picked from a row of pills, rather than
|
||||||
let open = index == 0 || title.seasons.len() == 1;
|
// every season stacked as a collapsing header: a four-season show
|
||||||
egui::CollapsingHeader::new(
|
// was a page of headers to scroll past to reach the one being
|
||||||
theme::Role::Subhead
|
// watched. The one opened first is the one with the next episode.
|
||||||
.rich(format!(
|
let picked_id = ui.id().with(("season", &title.key));
|
||||||
"{} · {}",
|
let default = title
|
||||||
season.label(),
|
.next_up()
|
||||||
ui::library::plural(season.episodes.len(), "episode")
|
.and_then(|next| {
|
||||||
))
|
title.seasons.iter().position(|season| {
|
||||||
.strong(),
|
season
|
||||||
)
|
.episodes
|
||||||
.id_salt(("season", index))
|
.iter()
|
||||||
.default_open(open)
|
.any(|episode| episode.node.link_id == next.node.link_id)
|
||||||
.show(ui, |ui| {
|
})
|
||||||
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
|
})
|
||||||
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
|
.unwrap_or(0);
|
||||||
if all_offline {
|
let mut picked = ui
|
||||||
if ui
|
.data(|data| data.get_temp::<usize>(picked_id))
|
||||||
.button("Delete season")
|
.unwrap_or(default)
|
||||||
.on_hover_text("Delete the offline copies; keep the online source")
|
.min(title.seasons.len().saturating_sub(1));
|
||||||
.clicked()
|
|
||||||
{
|
let Some(season) = title.seasons.get(picked) else {
|
||||||
for key in season_keys {
|
return;
|
||||||
actions.push(Action::RemoveDownload(key, false));
|
};
|
||||||
}
|
ui.horizontal(|ui| {
|
||||||
}
|
if title.seasons.len() > 1 {
|
||||||
} else if ui
|
let labels: Vec<String> =
|
||||||
.button("Download season")
|
title.seasons.iter().map(|season| season.label()).collect();
|
||||||
.on_hover_text("Download every episode in this season")
|
let choices: Vec<(usize, &str)> = labels
|
||||||
.clicked()
|
.iter()
|
||||||
{
|
.enumerate()
|
||||||
actions.push(Action::MakeOffline(
|
.map(|(index, label)| (index, label.as_str()))
|
||||||
season
|
.collect();
|
||||||
.episodes
|
if let Some(index) = ui::widgets::segmented(ui, picked, &choices) {
|
||||||
.iter()
|
picked = index;
|
||||||
.map(|episode| PlaybackTarget::new(title, episode))
|
ui.data_mut(|data| data.insert_temp(picked_id, index));
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for episode in &season.episodes {
|
|
||||||
episode_row(ui, art, title, season, episode, &offline, actions);
|
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(ui::library::plural(
|
||||||
|
season.episodes.len(),
|
||||||
|
"episode",
|
||||||
|
)));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
season_download(ui, title, season, &offline, actions);
|
||||||
});
|
});
|
||||||
ui.add_space(6.0);
|
});
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
|
let Some(season) = title.seasons.get(picked) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for episode in &season.episodes {
|
||||||
|
episode_row(ui, art, title, season, episode, &offline, actions);
|
||||||
}
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A title's poster, at the poster's own shape, with how far into it the
|
||||||
|
/// viewer is along its foot.
|
||||||
|
fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64>) {
|
||||||
|
let size = egui::vec2(180.0, 270.0);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||||
|
ui.painter()
|
||||||
|
.add(theme::tile_shadow(1.0).as_shape(rect, radius));
|
||||||
|
ui.painter().add(
|
||||||
|
egui::epaint::RectShape::filled(rect, radius, egui::Color32::WHITE)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), size)),
|
||||||
|
);
|
||||||
|
if let Some(progress) = progress {
|
||||||
|
let track = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left(), rect.bottom() - 4.0),
|
||||||
|
rect.right_bottom(),
|
||||||
|
);
|
||||||
|
let painter = ui.painter().with_clip_rect(track);
|
||||||
|
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
|
||||||
|
let mut played = rect;
|
||||||
|
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
|
||||||
|
theme::accent_fill(&painter, played, radius, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "← Library", as a link rather than a button: it is navigation, and a
|
||||||
|
/// filled button beside the page's real actions competed with them.
|
||||||
|
fn back_link(ui: &mut egui::Ui) -> egui::Response {
|
||||||
|
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
|
||||||
|
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
|
||||||
|
if response.hovered() {
|
||||||
|
ui.painter().hline(
|
||||||
|
response.rect.x_range(),
|
||||||
|
response.rect.bottom(),
|
||||||
|
egui::Stroke::new(1.0, theme::muted()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Back (Esc, Alt ←)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download, or delete, a whole season.
|
||||||
|
fn season_download(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
title: &Title,
|
||||||
|
season: &Season,
|
||||||
|
offline: &OfflineView<'_>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect();
|
||||||
|
let all_offline = season_keys.iter().all(|key| offline.files.contains(key));
|
||||||
|
if all_offline {
|
||||||
|
if ui
|
||||||
|
.button("Delete season")
|
||||||
|
.on_hover_text("Delete the offline copies; keep the online source")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
for key in season_keys {
|
||||||
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
|
.button("Download season")
|
||||||
|
.on_hover_text("Download every episode in this season")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(
|
||||||
|
season
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.map(|episode| PlaybackTarget::new(title, episode))
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The still, the name and the one button that matters.
|
/// The still, the name and the one button that matters.
|
||||||
fn header(
|
fn header(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
@@ -113,22 +201,34 @@ fn header(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
.map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop)));
|
||||||
|
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
ui::card(
|
match &picture {
|
||||||
ui,
|
// A poster is drawn as a poster. Fitted into the 16:9 tile the grid
|
||||||
Card {
|
// uses, it was a narrow strip of picture between two bars.
|
||||||
art: picture,
|
Some((texture, pstr_core::metadata::ArtShape::Portrait)) => {
|
||||||
name: &title.name,
|
poster(ui, texture, title.resume().and_then(|e| e.progress()));
|
||||||
subtitle: String::new(),
|
}
|
||||||
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
_ => {
|
||||||
badge: None,
|
ui::card(
|
||||||
width: theme::CARD_WIDTH,
|
ui,
|
||||||
},
|
Card {
|
||||||
);
|
art: picture,
|
||||||
|
name: &title.name,
|
||||||
|
subtitle: String::new(),
|
||||||
|
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
|
||||||
|
badge: None,
|
||||||
|
width: theme::CARD_WIDTH,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ui.add_space(18.0);
|
ui.add_space(theme::space::XXL);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
|
// Text in a column a person can read, with the picture to the
|
||||||
|
// right of it rather than under it.
|
||||||
|
ui.set_max_width(ui.available_width().min(820.0));
|
||||||
ui.label(theme::Role::Display.rich(&title.name).strong());
|
ui.label(theme::Role::Display.rich(&title.name).strong());
|
||||||
// The provider's name for it, when it is not the one the files use.
|
// The provider's name for it, when it is not the one the files use.
|
||||||
// Worth showing rather than replacing the filename's: a viewer
|
// Worth showing rather than replacing the filename's: a viewer
|
||||||
@@ -142,16 +242,24 @@ fn header(
|
|||||||
if by_hand { " (chosen by hand)" } else { "" }
|
if by_hand { " (chosen by hand)" } else { "" }
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(theme::space::XS);
|
||||||
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
ui.label(ui::muted(meta_line(title, found.as_ref())));
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if let Some(episode) = title.next_up() {
|
if let Some(episode) = title.next_up() {
|
||||||
let resuming = episode.resume_at().is_some();
|
let resuming = episode.resume_at().is_some();
|
||||||
|
// Where it resumes, in the button that resumes it. As a
|
||||||
|
// label of its own it ended up three controls further
|
||||||
|
// along, after the download buttons, reading as a fact
|
||||||
|
// about those.
|
||||||
|
let at = episode
|
||||||
|
.resume_at()
|
||||||
|
.map(|at| format!(" · {}", ui::format_time(at)))
|
||||||
|
.unwrap_or_default();
|
||||||
let label = match (resuming, episode.numbering()) {
|
let label = match (resuming, episode.numbering()) {
|
||||||
(true, Some(numbering)) => format!("Resume {numbering}"),
|
(true, Some(numbering)) => format!("Resume {numbering}{at}"),
|
||||||
(true, None) => "Resume".to_string(),
|
(true, None) => format!("Resume{at}"),
|
||||||
(false, Some(numbering)) if title.kind == TitleKind::Series => {
|
(false, Some(numbering)) if title.kind == TitleKind::Series => {
|
||||||
format!("Play {numbering}")
|
format!("Play {numbering}")
|
||||||
}
|
}
|
||||||
@@ -160,6 +268,19 @@ fn header(
|
|||||||
if ui::accent_button(ui, &label).clicked() {
|
if ui::accent_button(ui, &label).clicked() {
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
}
|
}
|
||||||
|
// Beside the button it is the alternative to.
|
||||||
|
if resuming
|
||||||
|
&& ui
|
||||||
|
.button("Start over")
|
||||||
|
.on_hover_text("Play from the beginning")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Play(PlaybackTarget::from_node(
|
||||||
|
title,
|
||||||
|
&episode.node,
|
||||||
|
None,
|
||||||
|
)));
|
||||||
|
}
|
||||||
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
let title_keys: Vec<_> = title.episodes().map(key_of).collect();
|
||||||
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
|
||||||
if all_offline {
|
if all_offline {
|
||||||
@@ -202,20 +323,6 @@ fn header(
|
|||||||
} else if all_offline {
|
} else if all_offline {
|
||||||
ui.label(ui::muted("Available offline"));
|
ui.label(ui::muted("Available offline"));
|
||||||
}
|
}
|
||||||
if resuming && let Some(at) = episode.resume_at() {
|
|
||||||
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
|
|
||||||
if ui
|
|
||||||
.button("Start over")
|
|
||||||
.on_hover_text("Play from the beginning")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::Play(PlaybackTarget::from_node(
|
|
||||||
title,
|
|
||||||
&episode.node,
|
|
||||||
None,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The way out of a match the scorer would not make, or made
|
// The way out of a match the scorer would not make, or made
|
||||||
@@ -235,14 +342,14 @@ fn header(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if let Some(found) = &found {
|
if let Some(found) = &found {
|
||||||
ui.add_space(14.0);
|
ui.add_space(theme::space::L);
|
||||||
description(ui, found);
|
description(ui, found);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some((texture, index)) = backdrop {
|
if let Some((texture, index)) = backdrop {
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
band(ui, &texture, index);
|
band(ui, &texture, index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +406,10 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
egui::epaint::RectShape::filled(
|
egui::epaint::RectShape::filled(
|
||||||
rect,
|
rect,
|
||||||
egui::CornerRadius::ZERO,
|
egui::CornerRadius::ZERO,
|
||||||
theme::background().gamma_multiply(if ramps { 0.90 } else { 0.94 }),
|
// Lighter with the ramps on: the horizontal one below is solid
|
||||||
|
// where the text is, so this coat only has to calm the picture's
|
||||||
|
// right side, not make it disappear.
|
||||||
|
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
|
||||||
)
|
)
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
@@ -340,14 +450,53 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
|
|||||||
/// What the provider had to say. Only ever drawn under a match.
|
/// What the provider had to say. Only ever drawn under a match.
|
||||||
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
|
||||||
if !found.genres.is_empty() {
|
if !found.genres.is_empty() {
|
||||||
ui.label(ui::muted(found.genres.join(" · ")));
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.add_space(6.0);
|
ui.spacing_mut().item_spacing.x = 6.0;
|
||||||
|
for genre in &found.genres {
|
||||||
|
ui::widgets::chip(ui, genre);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
}
|
}
|
||||||
if let Some(overview) = &found.overview {
|
if let Some(overview) = &found.overview {
|
||||||
ui.add(egui::Label::new(theme::Role::Label.rich(overview).color(theme::text())).wrap());
|
// Four lines, then "More". A provider's synopsis runs to three
|
||||||
|
// paragraphs on a long show, and all of them pushed the episodes —
|
||||||
|
// what the page is for — below the fold.
|
||||||
|
const LINES: usize = 4;
|
||||||
|
let id = ui.id().with(("overview", &found.remote_id));
|
||||||
|
let open = ui.data(|data| data.get_temp::<bool>(id)).unwrap_or(false);
|
||||||
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
|
overview.clone(),
|
||||||
|
theme::Role::Label.font(),
|
||||||
|
theme::text(),
|
||||||
|
ui.available_width(),
|
||||||
|
);
|
||||||
|
if !open {
|
||||||
|
job.wrap.max_rows = LINES;
|
||||||
|
job.wrap.overflow_character = Some('…');
|
||||||
|
}
|
||||||
|
let galley = ui.fonts_mut(|fonts| fonts.layout_job(job));
|
||||||
|
let clipped = galley.elided;
|
||||||
|
ui.label(galley);
|
||||||
|
if clipped || open {
|
||||||
|
let toggle = ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
theme::Role::Caption
|
||||||
|
.rich(if open { "Less" } else { "More" })
|
||||||
|
.color(theme::accent()),
|
||||||
|
)
|
||||||
|
.sense(egui::Sense::click()),
|
||||||
|
);
|
||||||
|
if toggle
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
ui.data_mut(|data| data.insert_temp(id, !open));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(url) = &found.url {
|
if let Some(url) = &found.url {
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
ui.hyperlink_to(
|
ui.hyperlink_to(
|
||||||
ui::muted(format!("More on {}", found.provider.label())),
|
ui::muted(format!("More on {}", found.provider.label())),
|
||||||
url,
|
url,
|
||||||
@@ -383,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
|
|
||||||
if let Some(found) = found {
|
if let Some(found) = found {
|
||||||
if let Some(rating) = found.rating {
|
if let Some(rating) = found.rating {
|
||||||
parts.push(format!("★ {rating:.1}"));
|
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
|
||||||
}
|
}
|
||||||
// Only when it disagrees with what is on disk: "25 episodes · 25
|
// Only when it disagrees with what is on disk: "25 episodes · 25
|
||||||
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
|
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
|
||||||
@@ -398,7 +547,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line per file: click anywhere on it to play.
|
/// How big an episode's still is drawn.
|
||||||
|
const STILL: egui::Vec2 = egui::vec2(160.0, 90.0);
|
||||||
|
|
||||||
|
/// One row per file: its still, its name and what it is about, and the two
|
||||||
|
/// things that can be done to it besides playing it.
|
||||||
fn episode_row(
|
fn episode_row(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
art: &mut Art<'_>,
|
art: &mut Art<'_>,
|
||||||
@@ -417,158 +570,311 @@ fn episode_row(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let watched = episode.is_watched();
|
let watched = episode.is_watched();
|
||||||
egui::Frame::new()
|
let row_id = ui.id().with(("episode", &episode.node.link_id));
|
||||||
.fill(if watched {
|
|
||||||
theme::background()
|
|
||||||
} else {
|
|
||||||
theme::card()
|
|
||||||
})
|
|
||||||
.corner_radius(egui::CornerRadius::same(6))
|
|
||||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_width(ui.available_width());
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
// An explicit target rather than a clickable row: the row also
|
|
||||||
// carries a checkbox, and a click that means "seen" must never
|
|
||||||
// be one that starts a 1.4 GiB stream instead.
|
|
||||||
if ui
|
|
||||||
.add(egui::Button::new(theme::Role::Label.rich("▶")).fill(theme::card_hover()))
|
|
||||||
.on_hover_text("Play")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
|
||||||
}
|
|
||||||
let key = key_of(episode);
|
|
||||||
let current = offline.downloads.iter().find(|item| item.key == key);
|
|
||||||
if offline.files.contains(&key) {
|
|
||||||
if ui
|
|
||||||
.small_button("Delete")
|
|
||||||
.on_hover_text("Delete the offline copy; keep the online source")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::RemoveDownload(key.clone(), false));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted("✓ Offline"));
|
|
||||||
} else if let Some(item) = current {
|
|
||||||
match item.state {
|
|
||||||
DownloadState::Running | DownloadState::Queued => {
|
|
||||||
if ui.small_button("Pause").clicked() {
|
|
||||||
actions.push(Action::PauseDownload(key.clone()));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0)));
|
|
||||||
}
|
|
||||||
DownloadState::Paused => {
|
|
||||||
if ui.small_button("Resume").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(key.clone()));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted("Paused · partial kept"));
|
|
||||||
}
|
|
||||||
DownloadState::Cancelled | DownloadState::Failed(_) => {
|
|
||||||
if ui.small_button("Resume").clicked() {
|
|
||||||
actions.push(Action::ResumeDownload(key.clone()));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted("Partial"));
|
|
||||||
}
|
|
||||||
DownloadState::Completed => {
|
|
||||||
ui.label(ui::muted("✓ Offline"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ui
|
|
||||||
.small_button("Download")
|
|
||||||
.on_hover_text("Make available offline")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
|
|
||||||
title, episode,
|
|
||||||
)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
let numbering = episode
|
let frame = egui::Frame::new()
|
||||||
.numbering()
|
.corner_radius(egui::CornerRadius::same(theme::radius::LG))
|
||||||
.or_else(|| season.number.map(|number| format!("S{number:02}")))
|
.inner_margin(egui::Margin::same(8));
|
||||||
.unwrap_or_default();
|
let mut prepared = frame.begin(ui);
|
||||||
ui.add_sized(
|
{
|
||||||
[66.0, 18.0],
|
let ui = &mut prepared.content_ui;
|
||||||
egui::Label::new(
|
ui.set_width(ui.available_width());
|
||||||
egui::RichText::new(numbering)
|
ui.horizontal_top(|ui| {
|
||||||
.monospace()
|
still(ui, art, title, episode, watched, STILL, actions);
|
||||||
.color(theme::muted()),
|
ui.add_space(theme::space::S);
|
||||||
),
|
|
||||||
);
|
let controls = 96.0;
|
||||||
|
let text_width = (ui.available_width() - controls).max(120.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(text_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = 3.0;
|
||||||
|
|
||||||
// The provider's name for the episode, when there is one:
|
// The provider's name for the episode, when there is one:
|
||||||
// "The Immortal Legion" reads as an episode, and
|
// "The Immortal Legion" reads as an episode, and
|
||||||
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
|
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
|
||||||
|
let numbering = episode
|
||||||
|
.numbering()
|
||||||
|
.or_else(|| season.number.map(|number| format!("S{number:02}")))
|
||||||
|
.unwrap_or_default();
|
||||||
let name = found
|
let name = found
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|(name, _, _)| name.clone())
|
.and_then(|(name, _, _)| name.clone())
|
||||||
.unwrap_or_else(|| episode.detail().to_string());
|
.unwrap_or_else(|| episode.detail().to_string());
|
||||||
let label = egui::RichText::new(name).color(if watched {
|
let mut job = egui::text::LayoutJob::default();
|
||||||
theme::muted()
|
if !numbering.is_empty() {
|
||||||
} else {
|
job.append(
|
||||||
theme::text()
|
&format!("{numbering} "),
|
||||||
});
|
0.0,
|
||||||
let hover = match &found {
|
egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()),
|
||||||
// The synopsis, where the provider has one — AniList does
|
);
|
||||||
// not, and the filename is worth showing either way.
|
|
||||||
Some((_, Some(overview), _)) => {
|
|
||||||
format!("{}\n\n{overview}", episode.node.name)
|
|
||||||
}
|
|
||||||
_ => episode.node.name.clone(),
|
|
||||||
};
|
|
||||||
ui.add(egui::Label::new(label).truncate())
|
|
||||||
.on_hover_text(hover);
|
|
||||||
|
|
||||||
if let Some((_, _, Some(air_date))) = &found {
|
|
||||||
ui.label(ui::muted(air_date.clone()));
|
|
||||||
}
|
}
|
||||||
|
job.append(
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
&name,
|
||||||
let mut checked = watched;
|
0.0,
|
||||||
if ui
|
egui::TextFormat::simple(
|
||||||
.checkbox(&mut checked, "")
|
theme::Role::Body.font(),
|
||||||
.on_hover_text(if watched {
|
if watched {
|
||||||
"Mark unwatched"
|
theme::muted()
|
||||||
} else {
|
} else {
|
||||||
"Mark watched"
|
theme::text()
|
||||||
})
|
},
|
||||||
.changed()
|
),
|
||||||
{
|
);
|
||||||
actions.push(Action::SetWatched {
|
job.wrap.max_rows = 1;
|
||||||
share_id: episode.node.share_id.clone(),
|
job.wrap.overflow_character = Some('…');
|
||||||
link_id: episode.node.link_id.clone(),
|
ui.add(egui::Label::new(job).truncate())
|
||||||
watched: checked,
|
.on_hover_text(&episode.node.name);
|
||||||
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
match episode.node.size {
|
ui.label(ui::muted(details(
|
||||||
// A file the share says is empty is one that will not
|
episode,
|
||||||
// play — an upload that never finished, usually. Worth
|
found.as_ref().and_then(|f| f.2.as_deref()),
|
||||||
// saying so on the row rather than only when a click on
|
)));
|
||||||
// it comes back with "has no content".
|
|
||||||
Some(0) => {
|
if let Some((_, Some(overview), _)) = &found {
|
||||||
ui.label(theme::Role::Caption.rich("empty").color(theme::danger()))
|
let mut job = egui::text::LayoutJob::simple(
|
||||||
.on_hover_text(
|
overview.clone(),
|
||||||
"The share reports no content for this file; it cannot be played.",
|
theme::Role::Caption.font(),
|
||||||
);
|
theme::muted(),
|
||||||
}
|
text_width,
|
||||||
Some(size) => {
|
);
|
||||||
ui.label(ui::muted(ui::format_size(size)));
|
job.wrap.max_rows = 2;
|
||||||
}
|
job.wrap.overflow_character = Some('…');
|
||||||
None => {}
|
ui.add(egui::Label::new(job)).on_hover_text(overview);
|
||||||
}
|
}
|
||||||
if let Some(at) = episode.resume_at() {
|
});
|
||||||
ui.label(
|
|
||||||
theme::Role::Caption
|
// A fixed height, the still's. `with_layout` would hand the
|
||||||
.rich(format!("resume {}", ui::format_time(at)))
|
// buttons all the height left in the scroll area's viewport and
|
||||||
.color(theme::accent()),
|
// centre them in it, so whichever row sat highest on screen
|
||||||
);
|
// stretched down to the bottom of the window.
|
||||||
}
|
let cluster = egui::vec2(ui.available_width(), STILL.y);
|
||||||
});
|
let layout = egui::Layout::right_to_left(egui::Align::Center);
|
||||||
|
ui.allocate_ui_with_layout(cluster, layout, |ui| {
|
||||||
|
if ui::widgets::watched_mark(ui, watched)
|
||||||
|
.on_hover_text(if watched {
|
||||||
|
"Mark unwatched"
|
||||||
|
} else {
|
||||||
|
"Mark watched"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetWatched {
|
||||||
|
share_id: episode.node.share_id.clone(),
|
||||||
|
link_id: episode.node.link_id.clone(),
|
||||||
|
watched: !watched,
|
||||||
|
duration: episode.watch.and_then(|watch| watch.duration_secs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
download(ui, title, episode, offline, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
}
|
||||||
|
// The row lights up under the pointer, so it is clear which file the
|
||||||
|
// buttons on the right belong to in a list of thirty.
|
||||||
|
let response = prepared.allocate_space(ui);
|
||||||
|
let lit = ui.ctx().animate_bool_with_time(
|
||||||
|
row_id,
|
||||||
|
response.hovered() || response.contains_pointer(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit);
|
||||||
|
prepared.paint(ui);
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The grey line under an episode's name: when it aired, how big it is, and
|
||||||
|
/// where it would resume.
|
||||||
|
fn details(episode: &Episode, air_date: Option<&str>) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(at) = episode.resume_at() {
|
||||||
|
let left = episode
|
||||||
|
.watch
|
||||||
|
.and_then(|watch| watch.duration_secs)
|
||||||
|
.map(|duration| format!(" · {} left", ui::format_time(duration - at)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts.push(format!("Resume at {}{left}", ui::format_time(at)));
|
||||||
|
} else if episode.is_watched() {
|
||||||
|
parts.push("Watched".into());
|
||||||
|
}
|
||||||
|
if let Some(date) = air_date {
|
||||||
|
parts.push(date.to_owned());
|
||||||
|
}
|
||||||
|
match episode.node.size {
|
||||||
|
// A file the share says is empty is one that will not play — an upload
|
||||||
|
// that never finished, usually. Worth saying on the row rather than
|
||||||
|
// only when a click on it comes back with "has no content".
|
||||||
|
Some(0) => parts.push("empty — this file cannot be played".into()),
|
||||||
|
Some(size) => parts.push(ui::format_size(size)),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The still, which is also the play button.
|
||||||
|
///
|
||||||
|
/// An explicit target rather than a clickable row: the row also carries the
|
||||||
|
/// watched mark and the download button, and a click that meant either of
|
||||||
|
/// those must never be one that starts a 1.4 GiB stream instead.
|
||||||
|
pub(crate) fn still(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
art: &mut Art<'_>,
|
||||||
|
title: &Title,
|
||||||
|
episode: &Episode,
|
||||||
|
watched: bool,
|
||||||
|
size: egui::Vec2,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let radius = egui::CornerRadius::same(theme::radius::MD);
|
||||||
|
let painter = ui.painter();
|
||||||
|
painter.rect_filled(rect, radius, theme::card_hover());
|
||||||
|
// Asked for only once the row is on screen: a season of twenty-five is
|
||||||
|
// twenty-five requests, and most of them are never scrolled to.
|
||||||
|
if let Some(texture) = art.still(&title.key, episode) {
|
||||||
|
let tint = if watched {
|
||||||
|
egui::Color32::from_gray(150)
|
||||||
|
} else {
|
||||||
|
egui::Color32::WHITE
|
||||||
|
};
|
||||||
|
painter.add(
|
||||||
|
egui::epaint::RectShape::filled(rect, radius, tint)
|
||||||
|
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.gained_focus() {
|
||||||
|
response.scroll_to_me(None);
|
||||||
|
}
|
||||||
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id,
|
||||||
|
response.hovered() || response.has_focus(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
if hover > 0.0 {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Color32::from_black_alpha((90.0 * hover) as u8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if response.has_focus() {
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(2.0, theme::accent()),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A play mark, always faintly there and full under the pointer.
|
||||||
|
let centre = rect.center();
|
||||||
|
painter.circle_filled(
|
||||||
|
centre,
|
||||||
|
18.0,
|
||||||
|
egui::Color32::from_black_alpha((110.0 + 80.0 * hover) as u8),
|
||||||
|
);
|
||||||
|
let ink = egui::Color32::WHITE.gamma_multiply(0.75 + 0.25 * hover);
|
||||||
|
let r = 7.0;
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
vec![
|
||||||
|
centre + egui::vec2(-r * 0.6, -r),
|
||||||
|
centre + egui::vec2(r, 0.0),
|
||||||
|
centre + egui::vec2(-r * 0.6, r),
|
||||||
|
],
|
||||||
|
ink,
|
||||||
|
egui::Stroke::NONE,
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(progress) = episode.progress() {
|
||||||
|
let track = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left(), rect.bottom() - 4.0),
|
||||||
|
rect.right_bottom(),
|
||||||
|
);
|
||||||
|
let painter = painter.with_clip_rect(track);
|
||||||
|
painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150));
|
||||||
|
let mut played = rect;
|
||||||
|
played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32);
|
||||||
|
theme::accent_fill(&painter, played, radius, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if response
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.on_hover_text(if episode.resume_at().is_some() {
|
||||||
|
"Resume"
|
||||||
|
} else {
|
||||||
|
"Play"
|
||||||
|
})
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One button that is the file's whole offline story: download it, see it
|
||||||
|
/// coming, pause it, or delete the copy.
|
||||||
|
fn download(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
title: &Title,
|
||||||
|
episode: &Episode,
|
||||||
|
offline: &OfflineView<'_>,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
) {
|
||||||
|
use ui::widgets::{DownloadGlyph, download_button};
|
||||||
|
|
||||||
|
let key = key_of(episode);
|
||||||
|
let current = offline.downloads.iter().find(|item| item.key == key);
|
||||||
|
if offline.files.contains(&key) {
|
||||||
|
if download_button(ui, DownloadGlyph::Done)
|
||||||
|
.on_hover_text("Available offline. Click to delete the copy; the share keeps the file.")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::RemoveDownload(key, false));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match current.map(|item| (&item.state, item.percent())) {
|
||||||
|
Some((DownloadState::Running | DownloadState::Queued, fraction)) => {
|
||||||
|
if download_button(ui, DownloadGlyph::Progress(fraction))
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Downloading, {:.0}%. Click to pause.",
|
||||||
|
fraction * 100.0
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::PauseDownload(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
DownloadState::Paused | DownloadState::Cancelled | DownloadState::Failed(_),
|
||||||
|
fraction,
|
||||||
|
)) => {
|
||||||
|
if download_button(ui, DownloadGlyph::Paused(fraction))
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Stopped at {:.0}%. Click to carry on.",
|
||||||
|
fraction * 100.0
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::ResumeDownload(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((DownloadState::Completed, _)) => {
|
||||||
|
download_button(ui, DownloadGlyph::Done).on_hover_text("Available offline");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if download_button(ui, DownloadGlyph::Download)
|
||||||
|
.on_hover_text("Download, to play without a connection")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::MakeOffline(vec![PlaybackTarget::new(
|
||||||
|
title, episode,
|
||||||
|
)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key_of(episode: &Episode) -> DownloadKey {
|
fn key_of(episode: &Episode) -> DownloadKey {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//! Short messages in the corner, for a few seconds.
|
||||||
|
//!
|
||||||
|
//! They replaced a single status line along the bottom of the window, which had
|
||||||
|
//! three problems a stack does not: a second message wiped the first before it
|
||||||
|
//! could be read, an error looked like any other line in a slightly different
|
||||||
|
//! grey, and the line took a strip of the page with it every time it appeared.
|
||||||
|
|
||||||
|
use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||||
|
|
||||||
|
use crate::app::Action;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/// How long a message stays up.
|
||||||
|
const SECONDS: f64 = 5.0;
|
||||||
|
/// How long an error stays up. Longer: it is more likely to be read twice.
|
||||||
|
const ERROR_SECONDS: f64 = 9.0;
|
||||||
|
/// How many fit at once. A burst beyond that drops the oldest.
|
||||||
|
const MOST: usize = 4;
|
||||||
|
/// How long one takes to arrive and to leave.
|
||||||
|
const FADE: f64 = theme::motion::FADE as f64;
|
||||||
|
const WIDTH: f32 = 340.0;
|
||||||
|
|
||||||
|
pub struct Toast {
|
||||||
|
id: u64,
|
||||||
|
text: String,
|
||||||
|
error: bool,
|
||||||
|
at: f64,
|
||||||
|
/// A button on the toast — "Undo" — and what it does.
|
||||||
|
action: Option<(&'static str, Action)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The messages on screen.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Toasts {
|
||||||
|
items: Vec<Toast>,
|
||||||
|
next: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Toasts {
|
||||||
|
pub fn push(&mut self, now: f64, text: impl Into<String>, error: bool) {
|
||||||
|
self.push_inner(now, text.into(), error, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message with a button on it, which runs `action` when pressed.
|
||||||
|
pub fn push_with(
|
||||||
|
&mut self,
|
||||||
|
now: f64,
|
||||||
|
text: impl Into<String>,
|
||||||
|
label: &'static str,
|
||||||
|
action: Action,
|
||||||
|
) {
|
||||||
|
self.push_inner(now, text.into(), false, Some((label, action)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_inner(
|
||||||
|
&mut self,
|
||||||
|
now: f64,
|
||||||
|
text: String,
|
||||||
|
error: bool,
|
||||||
|
action: Option<(&'static str, Action)>,
|
||||||
|
) {
|
||||||
|
// The same message again — a retry that failed the same way — restarts
|
||||||
|
// the one already up rather than stacking a copy of it.
|
||||||
|
if let Some(existing) = self.items.iter_mut().find(|toast| toast.text == text) {
|
||||||
|
existing.at = now;
|
||||||
|
existing.error = error;
|
||||||
|
existing.action = action;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.next += 1;
|
||||||
|
self.items.push(Toast {
|
||||||
|
id: self.next,
|
||||||
|
text,
|
||||||
|
error,
|
||||||
|
at: now,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
if self.items.len() > MOST {
|
||||||
|
self.items.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the stack above `bottom` points from the window's bottom edge, and
|
||||||
|
/// drop whatever has expired or been closed. Returns the action of a
|
||||||
|
/// toast whose button was pressed.
|
||||||
|
pub fn show(&mut self, ctx: &egui::Context, bottom: f32) -> Option<Action> {
|
||||||
|
let now = ctx.input(|input| input.time);
|
||||||
|
let pointer = ctx.input(|input| input.pointer.hover_pos());
|
||||||
|
let mut closed = None;
|
||||||
|
let mut pressed = None;
|
||||||
|
let mut hovered = false;
|
||||||
|
let mut y = ctx.content_rect().bottom() - bottom - 16.0;
|
||||||
|
let right = ctx.content_rect().right() - 16.0;
|
||||||
|
|
||||||
|
// Newest at the bottom, nearest where the eye already is.
|
||||||
|
for toast in self.items.iter().rev() {
|
||||||
|
let life = lifetime(toast.error);
|
||||||
|
let age = now - toast.at;
|
||||||
|
let arriving = (age / FADE).clamp(0.0, 1.0) as f32;
|
||||||
|
let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32;
|
||||||
|
let opacity = theme::motion::ease_out(arriving.min(leaving));
|
||||||
|
|
||||||
|
let area = egui::Area::new(egui::Id::new(("toast", toast.id)))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.pivot(Align2::RIGHT_BOTTOM)
|
||||||
|
.fixed_pos(egui::pos2(right + (1.0 - opacity) * 24.0, y))
|
||||||
|
.interactable(true)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_opacity(opacity);
|
||||||
|
card(ui, toast)
|
||||||
|
});
|
||||||
|
match area.inner {
|
||||||
|
Pressed::Close => closed = Some(toast.id),
|
||||||
|
Pressed::Action => pressed = Some(toast.id),
|
||||||
|
Pressed::Nothing => {}
|
||||||
|
}
|
||||||
|
if pointer.is_some_and(|at| area.response.rect.contains(at)) {
|
||||||
|
hovered = true;
|
||||||
|
}
|
||||||
|
y -= area.response.rect.height() + 8.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = closed {
|
||||||
|
self.items.retain(|toast| toast.id != id);
|
||||||
|
}
|
||||||
|
// Pressed once, and gone: an "Undo" left up after it ran is an
|
||||||
|
// invitation to undo the undo, which it does not do.
|
||||||
|
let action = pressed.and_then(|id| {
|
||||||
|
let index = self.items.iter().position(|toast| toast.id == id)?;
|
||||||
|
self.items.remove(index).action.map(|(_, action)| action)
|
||||||
|
});
|
||||||
|
// Under the pointer, nothing leaves: someone is reading it.
|
||||||
|
if hovered {
|
||||||
|
for toast in &mut self.items {
|
||||||
|
let life = lifetime(toast.error);
|
||||||
|
toast.at = toast.at.max(now - life + 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.items
|
||||||
|
.retain(|toast| now - toast.at < lifetime(toast.error));
|
||||||
|
if !self.items.is_empty() {
|
||||||
|
// Enough frames to animate the edges and to notice an expiry.
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifetime(error: bool) -> f64 {
|
||||||
|
if error { ERROR_SECONDS } else { SECONDS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of a toast's buttons was pressed.
|
||||||
|
enum Pressed {
|
||||||
|
Nothing,
|
||||||
|
Close,
|
||||||
|
Action,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One message. Returns which of its buttons was pressed.
|
||||||
|
fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed {
|
||||||
|
let mut pressed = Pressed::Nothing;
|
||||||
|
let edge = if toast.error {
|
||||||
|
theme::danger()
|
||||||
|
} else {
|
||||||
|
theme::accent()
|
||||||
|
};
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::surface())
|
||||||
|
.stroke(Stroke::new(1.0, theme::card_hover()))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin {
|
||||||
|
left: 16,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10,
|
||||||
|
})
|
||||||
|
.shadow(egui::Shadow {
|
||||||
|
offset: [0, 6],
|
||||||
|
blur: 18,
|
||||||
|
spread: 0,
|
||||||
|
color: Color32::from_black_alpha(90),
|
||||||
|
})
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(WIDTH);
|
||||||
|
let frame = ui.max_rect();
|
||||||
|
// The buttons first, from the right, so the text wraps in what
|
||||||
|
// they leave rather than pushing them off the card.
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::click());
|
||||||
|
let ink = if response.hovered() {
|
||||||
|
theme::text()
|
||||||
|
} else {
|
||||||
|
theme::muted()
|
||||||
|
};
|
||||||
|
let d = 4.0;
|
||||||
|
let c = rect.center();
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, -d), c + Vec2::new(d, d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[c + Vec2::new(-d, d), c + Vec2::new(d, -d)],
|
||||||
|
Stroke::new(1.5, ink),
|
||||||
|
);
|
||||||
|
if response.on_hover_text("Dismiss").clicked() {
|
||||||
|
pressed = Pressed::Close;
|
||||||
|
}
|
||||||
|
if let Some((label, _)) = &toast.action {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
theme::Role::Label
|
||||||
|
.rich(*label)
|
||||||
|
.strong()
|
||||||
|
.color(theme::accent()),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
pressed = Pressed::Action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(theme::Role::Label.rich(&toast.text).color(theme::text()))
|
||||||
|
.wrap(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// A strip down the leading edge says which kind it is before a word
|
||||||
|
// of it is read.
|
||||||
|
let strip = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(frame.left() - 10.0, frame.top() - 4.0),
|
||||||
|
egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0),
|
||||||
|
);
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge);
|
||||||
|
});
|
||||||
|
pressed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_message_restarts_rather_than_stacks() {
|
||||||
|
let mut toasts = Toasts::default();
|
||||||
|
toasts.push(0.0, "could not open", true);
|
||||||
|
toasts.push(3.0, "could not open", true);
|
||||||
|
assert_eq!(toasts.items.len(), 1);
|
||||||
|
assert_eq!(toasts.items[0].at, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_burst_keeps_only_the_newest() {
|
||||||
|
let mut toasts = Toasts::default();
|
||||||
|
for index in 0..10 {
|
||||||
|
toasts.push(0.0, format!("message {index}"), false);
|
||||||
|
}
|
||||||
|
assert_eq!(toasts.items.len(), MOST);
|
||||||
|
assert_eq!(toasts.items.last().unwrap().text, "message 9");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ pub fn full(
|
|||||||
) {
|
) {
|
||||||
seek_bar(ui, playback, actions, 6.0);
|
seek_bar(ui, playback, actions, 6.0);
|
||||||
times(ui, playback, true);
|
times(ui, playback, true);
|
||||||
ui.add_space(6.0);
|
ui.add_space(theme::space::S);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.set_min_height(ROW_HEIGHT_FULL);
|
ui.set_min_height(ROW_HEIGHT_FULL);
|
||||||
@@ -65,7 +65,7 @@ pub fn full(
|
|||||||
// hand reaches for it.
|
// hand reaches for it.
|
||||||
step_button(
|
step_button(
|
||||||
ui,
|
ui,
|
||||||
"⏮",
|
egui_phosphor::regular::SKIP_BACK,
|
||||||
"Previous (P)",
|
"Previous (P)",
|
||||||
neighbours.previous,
|
neighbours.previous,
|
||||||
actions,
|
actions,
|
||||||
@@ -76,14 +76,21 @@ pub fn full(
|
|||||||
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
|
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
|
||||||
play_pause(ui, playback, actions, 44.0);
|
play_pause(ui, playback, actions, 44.0);
|
||||||
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
|
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
|
||||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
step_button(
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
ui,
|
||||||
});
|
egui_phosphor::regular::SKIP_FORWARD,
|
||||||
|
"Next (N)",
|
||||||
|
neighbours.next,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(theme::space::L);
|
||||||
if playback.seeking || !playback.loaded {
|
if playback.seeking || playback.buffering || !playback.loaded {
|
||||||
ui.add(egui::Spinner::new().size(16.0));
|
ui.add(egui::Spinner::new().size(16.0));
|
||||||
ui.label(ui::muted(if playback.loaded {
|
ui.label(ui::muted(if playback.loaded && playback.seeking {
|
||||||
"seeking"
|
"seeking"
|
||||||
} else {
|
} else {
|
||||||
"buffering"
|
"buffering"
|
||||||
@@ -93,91 +100,153 @@ pub fn full(
|
|||||||
// Right cluster: everything that is a choice rather than a move.
|
// Right cluster: everything that is a choice rather than a move.
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
track_menus(ui, playback, actions);
|
track_menus(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
|
speed_menu(ui, playback, actions);
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
chapter_menu(ui, playback, actions);
|
chapter_menu(ui, playback, actions);
|
||||||
ui.add_space(8.0);
|
ui.add_space(theme::space::M);
|
||||||
volume(ui, playback, actions);
|
volume(ui, playback, actions);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strip under the library: what is playing, and back to it.
|
/// The strip under the library: what is playing, and back to it.
|
||||||
|
/// How big the live picture in the mini-player is drawn.
|
||||||
|
pub const MINI_PICTURE: Vec2 = Vec2::new(112.0, 63.0);
|
||||||
|
|
||||||
pub fn mini(
|
pub fn mini(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
playback: &Playback,
|
playback: &Playback,
|
||||||
|
picture: Option<egui::TextureId>,
|
||||||
neighbours: Neighbours,
|
neighbours: Neighbours,
|
||||||
actions: &mut Vec<Action>,
|
actions: &mut Vec<Action>,
|
||||||
) {
|
) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.set_min_height(ROW_HEIGHT_MINI);
|
// The way back to the picture, first, because leaving the player page
|
||||||
|
// with Esc is otherwise a one-way trip: playback carries on with
|
||||||
// The way back to the picture. First, and a real button rather than a
|
// nowhere to watch it. The picture itself when there is one — it is
|
||||||
// label, because leaving the player page with Esc is otherwise a
|
// both the reminder that something is playing and the button.
|
||||||
// one-way trip: playback carries on with nowhere to watch it.
|
match picture {
|
||||||
if ui
|
Some(texture) => {
|
||||||
.add(
|
let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click());
|
||||||
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
let painter = ui.painter();
|
||||||
.fill(theme::card_hover())
|
let radius = CornerRadius::same(theme::radius::SM);
|
||||||
.corner_radius(CornerRadius::same(8)),
|
painter.rect_filled(rect, radius, Color32::BLACK);
|
||||||
)
|
painter.add(
|
||||||
.on_hover_text("Show the picture again")
|
egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture(
|
||||||
.clicked()
|
texture,
|
||||||
{
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||||
actions.push(Action::Goto(Page::Player));
|
),
|
||||||
|
);
|
||||||
|
let hover = ui.ctx().animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
if hover > 0.0 {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
Color32::from_black_alpha((110.0 * hover) as u8),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
"Expand",
|
||||||
|
theme::Role::Caption.font(),
|
||||||
|
Color32::WHITE.gamma_multiply(hover),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(2.0, theme::accent().gamma_multiply(hover)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if response.on_hover_text("Show the picture again").clicked() {
|
||||||
|
actions.push(Action::Goto(Page::Player));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(theme::Role::Label.rich("Back to video"))
|
||||||
|
.fill(theme::card_hover())
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Show the picture again")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Goto(Page::Player));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ui.add_space(10.0);
|
ui.add_space(theme::space::L);
|
||||||
|
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
// Back to what is playing: after ten minutes of browsing, the bar
|
ui.horizontal(|ui| {
|
||||||
// is the only thing that still knows which title this came from.
|
ui.set_min_height(ROW_HEIGHT_MINI);
|
||||||
if ui
|
|
||||||
.add(
|
|
||||||
egui::Label::new(theme::Role::Body.rich(&playback.target.title_name).strong())
|
|
||||||
.sense(Sense::click()),
|
|
||||||
)
|
|
||||||
.on_hover_text("Show this title")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
|
|
||||||
}
|
|
||||||
ui.label(ui::muted(playback.target.caption()));
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.vertical(|ui| {
|
||||||
if ui.button("Stop").clicked() {
|
// Back to what is playing: after ten minutes of browsing, the bar
|
||||||
actions.push(Action::Player(Command::Stop));
|
// is the only thing that still knows which title this came from.
|
||||||
}
|
if ui
|
||||||
ui.add_space(6.0);
|
.add(
|
||||||
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
|
egui::Label::new(
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Next));
|
theme::Role::Body.rich(&playback.target.title_name).strong(),
|
||||||
|
)
|
||||||
|
.sense(Sense::click()),
|
||||||
|
)
|
||||||
|
.on_hover_text("Show this title")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
|
||||||
|
}
|
||||||
|
ui.label(ui::muted(playback.target.caption()));
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Stop").clicked() {
|
||||||
|
actions.push(Action::Player(Command::Stop));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
step_button(
|
||||||
|
ui,
|
||||||
|
egui_phosphor::regular::SKIP_FORWARD,
|
||||||
|
"Next (N)",
|
||||||
|
neighbours.next,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
play_pause(ui, playback, actions, 34.0);
|
||||||
|
step_button(
|
||||||
|
ui,
|
||||||
|
egui_phosphor::regular::SKIP_BACK,
|
||||||
|
"Previous (P)",
|
||||||
|
neighbours.previous,
|
||||||
|
actions,
|
||||||
|
|a| {
|
||||||
|
a.push(Action::PlayAdjacent(Adjacent::Previous));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
track_menus(ui, playback, actions);
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
volume(ui, playback, actions);
|
||||||
|
|
||||||
|
if playback.seeking || playback.buffering || !playback.loaded {
|
||||||
|
ui.add_space(theme::space::S);
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
play_pause(ui, playback, actions, 34.0);
|
ui.add_space(theme::space::XS);
|
||||||
step_button(
|
seek_bar(ui, playback, actions, 5.0);
|
||||||
ui,
|
times(ui, playback, false);
|
||||||
"⏮",
|
|
||||||
"Previous (P)",
|
|
||||||
neighbours.previous,
|
|
||||||
actions,
|
|
||||||
|a| {
|
|
||||||
a.push(Action::PlayAdjacent(Adjacent::Previous));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
track_menus(ui, playback, actions);
|
|
||||||
ui.add_space(6.0);
|
|
||||||
volume(ui, playback, actions);
|
|
||||||
|
|
||||||
if playback.seeking || !playback.loaded {
|
|
||||||
ui.add_space(6.0);
|
|
||||||
ui.add(egui::Spinner::new().size(14.0));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(4.0);
|
|
||||||
seek_bar(ui, playback, actions, 5.0);
|
|
||||||
times(ui, playback, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one button whose position should never move.
|
/// The one button whose position should never move.
|
||||||
@@ -359,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let icon = if playback.muted || playback.volume <= 0.0 {
|
let icon = if playback.muted || playback.volume <= 0.0 {
|
||||||
"🔇"
|
egui_phosphor::regular::SPEAKER_SLASH
|
||||||
|
} else if playback.volume < 50.0 {
|
||||||
|
egui_phosphor::regular::SPEAKER_LOW
|
||||||
} else {
|
} else {
|
||||||
"🔊"
|
egui_phosphor::regular::SPEAKER_HIGH
|
||||||
};
|
};
|
||||||
if ui
|
if ui
|
||||||
.button(icon)
|
.button(theme::Role::Subhead.rich(icon))
|
||||||
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
@@ -429,6 +500,32 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How fast it plays. Says so on the button only when it is not normal speed,
|
||||||
|
/// because that is the one case worth noticing at a glance.
|
||||||
|
fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
|
let label = if (playback.speed - 1.0).abs() < 1e-6 {
|
||||||
|
"Speed".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("Speed · {}", ui::format_speed(playback.speed))
|
||||||
|
};
|
||||||
|
ui.menu_button(label, |ui| {
|
||||||
|
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
|
||||||
|
if ui
|
||||||
|
.selectable_label(
|
||||||
|
(playback.speed - speed).abs() < 1e-6,
|
||||||
|
ui::format_speed(speed),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(Action::SetSpeed(speed));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
.on_hover_text("Playback speed ([ and ])");
|
||||||
|
}
|
||||||
|
|
||||||
/// Jump to a chapter. Only drawn for a file that has more than one, which for
|
/// Jump to a chapter. Only drawn for a file that has more than one, which for
|
||||||
/// most releases means anime and not much else.
|
/// most releases means anime and not much else.
|
||||||
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||||
@@ -470,66 +567,101 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
let (rect, response) =
|
let (rect, response) =
|
||||||
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
|
||||||
|
|
||||||
let hovered = response.hovered() || response.dragged();
|
let active = response.hovered() || response.dragged();
|
||||||
let track = Rect::from_center_size(
|
// Grows rather than jumps when the pointer arrives.
|
||||||
rect.center(),
|
let grow =
|
||||||
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
|
ui.ctx()
|
||||||
);
|
.animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER);
|
||||||
|
let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow));
|
||||||
|
let radius = CornerRadius::same(theme::radius::BAR);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52));
|
// From the palette rather than a fixed grey: the same bar sits on the
|
||||||
|
// video's dark scrim and on a light theme's transport strip.
|
||||||
|
painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35));
|
||||||
|
|
||||||
if let Some(progress) = playback.progress() {
|
let duration = playback.duration.filter(|duration| *duration > 0.0);
|
||||||
|
// What is already read ahead, a shade lighter than the empty track: on a
|
||||||
|
// link this slow, whether a jump lands in it is the difference between an
|
||||||
|
// instant seek and a fetch.
|
||||||
|
if let (Some(duration), Some(end)) = (duration, playback.buffered)
|
||||||
|
&& end > playback.position
|
||||||
|
{
|
||||||
|
let from = (playback.position / duration).clamp(0.0, 1.0) as f32;
|
||||||
|
let to = (end / duration).clamp(0.0, 1.0) as f32;
|
||||||
|
let buffered = Rect::from_min_max(
|
||||||
|
egui::pos2(track.left() + track.width() * from, track.top()),
|
||||||
|
egui::pos2(track.left() + track.width() * to, track.bottom()),
|
||||||
|
);
|
||||||
|
painter.rect_filled(buffered, radius, theme::muted().gamma_multiply(0.30));
|
||||||
|
}
|
||||||
|
let pointer = response
|
||||||
|
.interact_pointer_pos()
|
||||||
|
.or_else(|| response.hover_pos())
|
||||||
|
.map(|position| ((position.x - track.left()) / track.width()).clamp(0.0, 1.0));
|
||||||
|
|
||||||
|
// Where the pointer would land, lighter than what has been played: the bar
|
||||||
|
// shows the jump before it is made.
|
||||||
|
if let (Some(fraction), true) = (pointer, active) {
|
||||||
|
let mut ghost = track;
|
||||||
|
ghost.set_width(track.width() * fraction);
|
||||||
|
painter.rect_filled(ghost, radius, theme::muted().gamma_multiply(0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
// While dragging, the knob follows the pointer even though the seek waits
|
||||||
|
// for the release — see below. A knob that stays put under a moving hand
|
||||||
|
// reads as a bar that is not listening.
|
||||||
|
let shown = match (response.dragged(), pointer) {
|
||||||
|
(true, Some(fraction)) => Some(fraction),
|
||||||
|
_ => playback.progress(),
|
||||||
|
};
|
||||||
|
if let Some(progress) = shown {
|
||||||
let mut played = track;
|
let mut played = track;
|
||||||
played.set_width(track.width() * progress);
|
played.set_width(track.width() * progress);
|
||||||
// The gradient is sized to the *played* part, not to the whole bar, so
|
// The gradient is sized to the *played* part, not to the whole bar, so
|
||||||
// the far end of it arrives at the playhead rather than at the end of
|
// the far end of it arrives at the playhead rather than at the end of
|
||||||
// the file. A bar that only ever shows the first fifth of its own ramp
|
// the file. A bar that only ever shows the first fifth of its own ramp
|
||||||
// does not read as a gradient at all.
|
// does not read as a gradient at all.
|
||||||
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
|
theme::accent_fill(painter, played, radius, 0.0);
|
||||||
painter.circle_filled(
|
painter.circle_filled(
|
||||||
egui::pos2(played.right(), track.center().y),
|
egui::pos2(played.right(), track.center().y),
|
||||||
if hovered { 8.0 } else { 6.0 },
|
5.0 + 3.0 * grow,
|
||||||
Color32::WHITE,
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seeking needs a duration: without one there is nothing for a fraction of
|
// Seeking needs a duration: without one there is nothing for a fraction of
|
||||||
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
|
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
|
||||||
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else {
|
let Some(duration) = duration else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chapter marks, over the bar rather than under it: an opening is two
|
// Chapter marks, cut into the bar rather than drawn on it: an opening is
|
||||||
// minutes of a twenty-four minute file, and the point of the mark is to be
|
// two minutes of a twenty-four minute file, and the point of the mark is to
|
||||||
// able to aim just past it.
|
// be able to aim just past it.
|
||||||
for chapter in playback.chapters.iter().skip(1) {
|
for chapter in playback.chapters.iter().skip(1) {
|
||||||
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
|
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
|
||||||
let x = track.left() + track.width() * fraction;
|
let x = track.left() + track.width() * fraction;
|
||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
Rect::from_min_size(
|
Rect::from_min_size(
|
||||||
egui::pos2(x - 1.0, track.top() - 1.0),
|
egui::pos2(x - 1.5, track.top() - 1.0),
|
||||||
Vec2::new(2.0, track.height() + 2.0),
|
Vec2::new(3.0, track.height() + 2.0),
|
||||||
),
|
),
|
||||||
CornerRadius::ZERO,
|
CornerRadius::ZERO,
|
||||||
Color32::from_black_alpha(180),
|
Color32::from_black_alpha(200),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On release, not on every frame of the drag: each seek cancels outstanding
|
// On release, not on every frame of the drag: each seek cancels outstanding
|
||||||
// read-ahead and refetches, so a dragged bar that seeks continuously spends
|
// read-ahead and refetches, so a dragged bar that seeks continuously spends
|
||||||
// the whole drag throwing away blocks it just paid for.
|
// the whole drag throwing away blocks it just paid for.
|
||||||
if let Some(position) = response.interact_pointer_pos()
|
if let Some(fraction) = pointer
|
||||||
&& (response.clicked() || response.drag_stopped())
|
&& (response.clicked() || response.drag_stopped())
|
||||||
{
|
{
|
||||||
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
|
|
||||||
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
|
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.hovered()
|
if let (Some(fraction), true) = (pointer, active) {
|
||||||
&& let Some(position) = response.hover_pos()
|
let at = f64::from(fraction) * duration;
|
||||||
{
|
|
||||||
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
|
|
||||||
let at = fraction * duration;
|
|
||||||
// The chapter under the pointer, when there is one: "42:10 · OP" tells
|
// The chapter under the pointer, when there is one: "42:10 · OP" tells
|
||||||
// a viewer what they are about to land in, which is the whole reason to
|
// a viewer what they are about to land in, which is the whole reason to
|
||||||
// aim at a particular part of the bar.
|
// aim at a particular part of the bar.
|
||||||
@@ -541,6 +673,29 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
|
|||||||
),
|
),
|
||||||
None => ui::format_time(at),
|
None => ui::format_time(at),
|
||||||
};
|
};
|
||||||
response.clone().on_hover_text_at_pointer(hint);
|
bubble(
|
||||||
|
ui,
|
||||||
|
egui::pos2(track.left() + track.width() * fraction, track.top() - 10.0),
|
||||||
|
&hint,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small label floating above a point, kept inside the window.
|
||||||
|
fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) {
|
||||||
|
let painter = ui.ctx().layer_painter(egui::LayerId::new(
|
||||||
|
egui::Order::Tooltip,
|
||||||
|
egui::Id::new("seek-bubble"),
|
||||||
|
));
|
||||||
|
let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Label.font(), Color32::WHITE);
|
||||||
|
let size = galley.size() + Vec2::new(16.0, 8.0);
|
||||||
|
let screen = ui.ctx().content_rect();
|
||||||
|
let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0);
|
||||||
|
let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size);
|
||||||
|
painter.rect_filled(
|
||||||
|
frame,
|
||||||
|
CornerRadius::same(theme::radius::SM),
|
||||||
|
Color32::from_black_alpha(215),
|
||||||
|
);
|
||||||
|
painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//! Controls egui does not have, or has in a shape that does not fit.
|
||||||
|
//!
|
||||||
|
//! Each is painted by hand for the same reason [`super::accent_button`] is: an
|
||||||
|
//! egui widget takes one flat colour, and what makes these readable is either a
|
||||||
|
//! gradient or a state drawn as a shape rather than as a fill.
|
||||||
|
|
||||||
|
use egui::{Color32, CornerRadius, Sense, Stroke, Vec2};
|
||||||
|
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
/// An on/off switch with its label beside it, for settings.
|
||||||
|
///
|
||||||
|
/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour —
|
||||||
|
/// on a card, which is where every setting lives, an unchecked box was not
|
||||||
|
/// there at all. A switch says "off" with a shape, not with a fill that has to
|
||||||
|
/// stand out from what is behind it.
|
||||||
|
pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response {
|
||||||
|
const TRACK: Vec2 = Vec2::new(34.0, 20.0);
|
||||||
|
|
||||||
|
let galley = ui.painter().layout_no_wrap(
|
||||||
|
text.to_owned(),
|
||||||
|
theme::Role::Body.font(),
|
||||||
|
Color32::PLACEHOLDER,
|
||||||
|
);
|
||||||
|
let gap = if text.is_empty() { 0.0 } else { 10.0 };
|
||||||
|
let size = Vec2::new(
|
||||||
|
TRACK.x + gap + galley.size().x,
|
||||||
|
TRACK
|
||||||
|
.y
|
||||||
|
.max(galley.size().y)
|
||||||
|
.max(ui.spacing().interact_size.y),
|
||||||
|
);
|
||||||
|
let (rect, mut response) = ui.allocate_exact_size(size, Sense::click());
|
||||||
|
if response.clicked() {
|
||||||
|
*on = !*on;
|
||||||
|
response.mark_changed();
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *on, text));
|
||||||
|
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let ctx = ui.ctx();
|
||||||
|
let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE);
|
||||||
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
let track = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0),
|
||||||
|
TRACK,
|
||||||
|
);
|
||||||
|
let radius = CornerRadius::same((TRACK.y / 2.0) as u8);
|
||||||
|
let painter = ui.painter();
|
||||||
|
|
||||||
|
// Off is an outlined track; on is the accent filling it. Both are drawn
|
||||||
|
// through the switch's travel, so the fill arrives with the knob.
|
||||||
|
painter.rect_filled(track, radius, theme::card_hover());
|
||||||
|
painter.rect_stroke(
|
||||||
|
track,
|
||||||
|
radius,
|
||||||
|
Stroke::new(1.0, theme::muted().gamma_multiply(0.6 + 0.4 * hover)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
if t > 0.0 {
|
||||||
|
let mut lit = track;
|
||||||
|
lit.set_width(TRACK.y + (TRACK.x - TRACK.y) * t);
|
||||||
|
theme::accent_fill(painter, lit, radius, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let knob = egui::lerp(
|
||||||
|
(track.left() + TRACK.y / 2.0)..=(track.right() - TRACK.y / 2.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
let ink = theme::muted().lerp_to_gamma(Color32::WHITE, t);
|
||||||
|
painter.circle_filled(egui::pos2(knob, track.center().y), TRACK.y / 2.0 - 3.0, ink);
|
||||||
|
|
||||||
|
if response.has_focus() {
|
||||||
|
painter.rect_stroke(
|
||||||
|
track.expand(2.0),
|
||||||
|
CornerRadius::same((TRACK.y / 2.0 + 2.0) as u8),
|
||||||
|
Stroke::new(1.5, theme::accent()),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = egui::pos2(track.right() + gap, rect.center().y - galley.size().y / 2.0);
|
||||||
|
painter.galley(position, galley, theme::text());
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round mark that is either a filled tick or an empty ring: "watched".
|
||||||
|
///
|
||||||
|
/// The checkbox it replaced had the same invisible-when-off problem as the one
|
||||||
|
/// [`toggle`] replaced, and a list of episodes is the one place where the
|
||||||
|
/// unwatched state is the one that most needs to be seen.
|
||||||
|
pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response {
|
||||||
|
const SIZE: f32 = 20.0;
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let ctx = ui.ctx();
|
||||||
|
let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE);
|
||||||
|
let hover = ctx.animate_bool_with_time(
|
||||||
|
response.id.with("hover"),
|
||||||
|
response.hovered(),
|
||||||
|
theme::motion::HOVER,
|
||||||
|
);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let center = rect.center();
|
||||||
|
let radius = SIZE / 2.0 - 1.0;
|
||||||
|
|
||||||
|
if t > 0.0 {
|
||||||
|
painter.circle_filled(center, radius, theme::accent().gamma_multiply(t));
|
||||||
|
}
|
||||||
|
painter.circle_stroke(
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
Stroke::new(
|
||||||
|
1.5,
|
||||||
|
theme::muted()
|
||||||
|
.lerp_to_gamma(theme::text(), hover)
|
||||||
|
.gamma_multiply(1.0 - t),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The tick is drawn in both states: faintly, under the pointer, while
|
||||||
|
// unwatched — so the mark says what clicking it will do.
|
||||||
|
let ink = if watched {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
theme::muted().gamma_multiply(hover)
|
||||||
|
};
|
||||||
|
if watched || hover > 0.0 {
|
||||||
|
let points = [
|
||||||
|
center + Vec2::new(-4.5, 0.2),
|
||||||
|
center + Vec2::new(-1.4, 3.3),
|
||||||
|
center + Vec2::new(4.6, -3.2),
|
||||||
|
];
|
||||||
|
painter.line(points.to_vec(), Stroke::new(1.8, ink));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of settings: a heading, an optional line under it, and a card that
|
||||||
|
/// holds the rows.
|
||||||
|
///
|
||||||
|
/// The libadwaita preferences pattern, which is what `proton-drive-linux`
|
||||||
|
/// settles on: one card per subject, one row per setting, the setting's name
|
||||||
|
/// and what it does on the left and the control on the right — so the eye runs
|
||||||
|
/// down one edge for names and the other for state.
|
||||||
|
pub fn settings_group<R>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
heading: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
add: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
|
) -> R {
|
||||||
|
ui.add_space(theme::space::XS);
|
||||||
|
ui.label(
|
||||||
|
theme::Role::Subhead
|
||||||
|
.rich(heading)
|
||||||
|
.strong()
|
||||||
|
.color(theme::text()),
|
||||||
|
);
|
||||||
|
if let Some(description) = description {
|
||||||
|
ui.add_space(theme::space::XXS);
|
||||||
|
ui.label(crate::ui::muted(description));
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::M);
|
||||||
|
let inner = egui::Frame::new()
|
||||||
|
.fill(theme::card())
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(16, 6))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
add(ui)
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
ui.add_space(theme::space::XXL);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a [`settings_group`]: what it is on the left, the control on the
|
||||||
|
/// right. Rows after the first get a hairline above them.
|
||||||
|
pub fn settings_row<R>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
title: &str,
|
||||||
|
subtitle: Option<&str>,
|
||||||
|
control: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
|
) -> R {
|
||||||
|
let top = ui.cursor().top();
|
||||||
|
// The first row of a card starts at its top margin; anything below that
|
||||||
|
// has a row above it to be divided from.
|
||||||
|
if top > ui.max_rect().top() + 1.0 {
|
||||||
|
let y = top;
|
||||||
|
ui.painter().hline(
|
||||||
|
ui.max_rect().x_range(),
|
||||||
|
y,
|
||||||
|
Stroke::new(1.0, theme::card_hover()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
let inner = ui
|
||||||
|
.horizontal(|ui| {
|
||||||
|
ui.set_min_height(28.0);
|
||||||
|
let text_width = (ui.available_width() * 0.62).max(160.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_max_width(text_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = 2.0;
|
||||||
|
ui.label(theme::Role::Body.rich(title).color(theme::text()));
|
||||||
|
if let Some(subtitle) = subtitle {
|
||||||
|
ui.add(egui::Label::new(crate::ui::muted(subtitle)).wrap());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), control)
|
||||||
|
.inner
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
ui.add_space(theme::space::L);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row of choices where exactly one is on: pills on a shared track.
|
||||||
|
///
|
||||||
|
/// Returns the one clicked, if it was not already the chosen one.
|
||||||
|
pub fn segmented<T: Copy + PartialEq>(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
current: T,
|
||||||
|
choices: &[(T, &str)],
|
||||||
|
) -> Option<T> {
|
||||||
|
let mut picked = None;
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card_hover())
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::MD))
|
||||||
|
.inner_margin(egui::Margin::same(3))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (value, label) in choices {
|
||||||
|
let selected = *value == current;
|
||||||
|
let text = theme::Role::Label.rich(*label).color(if selected {
|
||||||
|
theme::on_accent()
|
||||||
|
} else {
|
||||||
|
theme::text()
|
||||||
|
});
|
||||||
|
let button = egui::Button::new(text)
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::SM))
|
||||||
|
.fill(if selected {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
})
|
||||||
|
.stroke(Stroke::NONE);
|
||||||
|
if ui.add(button).clicked() && !selected {
|
||||||
|
picked = Some(*value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
picked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a [`download_button`] shows.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum DownloadGlyph {
|
||||||
|
/// Not downloaded: an arrow into a tray.
|
||||||
|
Download,
|
||||||
|
/// Coming down: a ring filling with how far it has got.
|
||||||
|
Progress(f32),
|
||||||
|
/// Stopped part way: the ring, still, with a pause mark.
|
||||||
|
Paused(f32),
|
||||||
|
/// Here: a tick.
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round icon button for one file's download.
|
||||||
|
pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response {
|
||||||
|
const SIZE: f32 = 30.0;
|
||||||
|
let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click());
|
||||||
|
if !ui.is_rect_visible(rect) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
let hover =
|
||||||
|
ui.ctx()
|
||||||
|
.animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER);
|
||||||
|
let painter = ui.painter();
|
||||||
|
let c = rect.center();
|
||||||
|
if hover > 0.0 {
|
||||||
|
painter.circle_filled(c, SIZE / 2.0, theme::card_hover().gamma_multiply(hover));
|
||||||
|
}
|
||||||
|
let ink = theme::muted().lerp_to_gamma(theme::text(), hover);
|
||||||
|
let stroke = Stroke::new(1.6, ink);
|
||||||
|
let ring = |painter: &egui::Painter, fraction: f32| {
|
||||||
|
let radius = 9.0;
|
||||||
|
painter.circle_stroke(c, radius, Stroke::new(2.0, theme::card_hover()));
|
||||||
|
let steps = 40;
|
||||||
|
let points: Vec<egui::Pos2> = (0..=((steps as f32 * fraction.clamp(0.0, 1.0)) as usize))
|
||||||
|
.map(|step| {
|
||||||
|
let angle = -std::f32::consts::FRAC_PI_2
|
||||||
|
+ std::f32::consts::TAU * step as f32 / steps as f32;
|
||||||
|
c + Vec2::angled(angle) * radius
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if points.len() > 1 {
|
||||||
|
painter.add(egui::Shape::line(points, Stroke::new(2.0, theme::accent())));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match glyph {
|
||||||
|
DownloadGlyph::Download => {
|
||||||
|
painter.line_segment([c + Vec2::new(0.0, -6.0), c + Vec2::new(0.0, 3.0)], stroke);
|
||||||
|
painter.line(
|
||||||
|
vec![
|
||||||
|
c + Vec2::new(-4.0, -1.0),
|
||||||
|
c + Vec2::new(0.0, 3.0),
|
||||||
|
c + Vec2::new(4.0, -1.0),
|
||||||
|
],
|
||||||
|
stroke,
|
||||||
|
);
|
||||||
|
painter.line_segment([c + Vec2::new(-6.0, 7.0), c + Vec2::new(6.0, 7.0)], stroke);
|
||||||
|
}
|
||||||
|
DownloadGlyph::Progress(fraction) => {
|
||||||
|
ring(painter, fraction);
|
||||||
|
// Keeps a frame coming while the ring has somewhere to go.
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(250));
|
||||||
|
if hover > 0.0 {
|
||||||
|
for x in [-2.5, 2.5] {
|
||||||
|
painter.line_segment(
|
||||||
|
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
|
||||||
|
Stroke::new(1.8, ink.gamma_multiply(hover)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadGlyph::Paused(fraction) => {
|
||||||
|
ring(painter, fraction);
|
||||||
|
for x in [-2.5, 2.5] {
|
||||||
|
painter.line_segment(
|
||||||
|
[c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)],
|
||||||
|
Stroke::new(1.8, ink),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadGlyph::Done => {
|
||||||
|
painter.line(
|
||||||
|
vec![
|
||||||
|
c + Vec2::new(-5.0, 0.0),
|
||||||
|
c + Vec2::new(-1.5, 3.5),
|
||||||
|
c + Vec2::new(5.0, -3.5),
|
||||||
|
],
|
||||||
|
Stroke::new(2.0, theme::accent().lerp_to_gamma(ink, hover)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small rounded label: a genre, a year, a fact.
|
||||||
|
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(theme::card_hover().gamma_multiply(0.8))
|
||||||
|
.corner_radius(CornerRadius::same(theme::radius::LG))
|
||||||
|
.inner_margin(egui::Margin::symmetric(9, 3))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label(theme::Role::Caption.rich(text).color(theme::text()));
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
@@ -67,8 +67,10 @@ pub struct VideoSurface {
|
|||||||
impl VideoSurface {
|
impl VideoSurface {
|
||||||
/// Attach a renderer to `player` on the current OpenGL context.
|
/// Attach a renderer to `player` on the current OpenGL context.
|
||||||
///
|
///
|
||||||
/// `ctx` is woken whenever mpv has a new frame; without that the picture
|
/// `wake` is called whenever mpv has a new frame, and should ask the window
|
||||||
/// would only advance when the viewer moved the mouse.
|
/// for one. Without it the picture would only advance when the viewer moved
|
||||||
|
/// the mouse. The app's goes through [`crate::engine::Engine::wake`], which
|
||||||
|
/// knows whether the picture is on screen to be drawn at all.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
///
|
///
|
||||||
@@ -78,11 +80,11 @@ impl VideoSurface {
|
|||||||
pub unsafe fn new(
|
pub unsafe fn new(
|
||||||
player: Arc<Player>,
|
player: Arc<Player>,
|
||||||
gl: Arc<glow::Context>,
|
gl: Arc<glow::Context>,
|
||||||
ctx: egui::Context,
|
wake: impl Fn() + Send + Sync + 'static,
|
||||||
) -> pstr_player::Result<Self> {
|
) -> pstr_player::Result<Self> {
|
||||||
// SAFETY: forwarded from this function's own contract.
|
// SAFETY: forwarded from this function's own contract.
|
||||||
let mut renderer = unsafe { VideoRenderer::new(player)? };
|
let mut renderer = unsafe { VideoRenderer::new(player)? };
|
||||||
renderer.on_update(move || ctx.request_repaint());
|
renderer.on_update(wake);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
gl,
|
gl,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! The window's size, kept between launches.
|
||||||
|
//!
|
||||||
|
//! Size and maximized only. Position is left to the window manager: Wayland
|
||||||
|
//! does not let a client choose it, and on X11 a remembered position on a
|
||||||
|
//! monitor that has since been unplugged opens the window off screen.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What the next launch should open at.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WindowState {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The smallest size worth restoring. Anything below is a window that was
|
||||||
|
/// being dragged about when it closed, or a file someone edited by hand.
|
||||||
|
const SMALLEST: [f32; 2] = [720.0, 480.0];
|
||||||
|
|
||||||
|
impl WindowState {
|
||||||
|
/// The saved state, if there is a sensible one. A missing or broken file
|
||||||
|
/// means the default size, never an error: this is a convenience.
|
||||||
|
pub fn load(path: &Path) -> Option<Self> {
|
||||||
|
let state: Self = pstr_core::config::read_json(path).ok().flatten()?;
|
||||||
|
state.sensible().then_some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sensible(&self) -> bool {
|
||||||
|
self.width.is_finite()
|
||||||
|
&& self.height.is_finite()
|
||||||
|
&& self.width >= SMALLEST[0]
|
||||||
|
&& self.height >= SMALLEST[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold this frame's viewport into `last`.
|
||||||
|
///
|
||||||
|
/// A fullscreen window says nothing about how big the window should be, and
|
||||||
|
/// a maximized one says nothing about how big it should be once it is not,
|
||||||
|
/// so neither changes the size kept.
|
||||||
|
pub fn observe(ctx: &egui::Context, last: Option<Self>) -> Option<Self> {
|
||||||
|
let (size, maximized, fullscreen) = ctx.input(|input| {
|
||||||
|
let viewport = input.viewport();
|
||||||
|
(
|
||||||
|
viewport.inner_rect.map(|rect| rect.size()),
|
||||||
|
viewport.maximized.unwrap_or(false),
|
||||||
|
viewport.fullscreen.unwrap_or(false),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if fullscreen {
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
let (width, height) = match (maximized, last, size) {
|
||||||
|
(true, Some(last), _) => (last.width, last.height),
|
||||||
|
(_, _, Some(size)) => (size.x, size.y),
|
||||||
|
_ => return last,
|
||||||
|
};
|
||||||
|
let state = Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maximized,
|
||||||
|
};
|
||||||
|
state.sensible().then_some(state).or(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the state when it changes, once, at exit.
|
||||||
|
pub struct WindowMemory {
|
||||||
|
path: PathBuf,
|
||||||
|
saved: Option<WindowState>,
|
||||||
|
pub current: Option<WindowState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowMemory {
|
||||||
|
pub fn new(path: PathBuf) -> Self {
|
||||||
|
let saved = WindowState::load(&path);
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
saved,
|
||||||
|
current: saved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn observe(&mut self, ctx: &egui::Context) {
|
||||||
|
self.current = WindowState::observe(ctx, self.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) {
|
||||||
|
let Some(current) = self.current.filter(|current| Some(*current) != self.saved) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = pstr_core::config::write_json(&self.path, ¤t) {
|
||||||
|
tracing::debug!("save the window size: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_smaller_than_the_minimum_is_not_restored() {
|
||||||
|
let path = std::env::temp_dir().join(format!("pstr-window-{}.json", std::process::id()));
|
||||||
|
let tiny = WindowState {
|
||||||
|
width: 200.0,
|
||||||
|
height: 100.0,
|
||||||
|
maximized: false,
|
||||||
|
};
|
||||||
|
pstr_core::config::write_json(&path, &tiny).unwrap();
|
||||||
|
assert_eq!(WindowState::load(&path), None);
|
||||||
|
|
||||||
|
let normal = WindowState {
|
||||||
|
width: 1400.0,
|
||||||
|
height: 900.0,
|
||||||
|
maximized: true,
|
||||||
|
};
|
||||||
|
pstr_core::config::write_json(&path, &normal).unwrap();
|
||||||
|
assert_eq!(WindowState::load(&path), Some(normal));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
|||||||
use crate::naming::{self, ParsedName};
|
use crate::naming::{self, ParsedName};
|
||||||
|
|
||||||
/// Bump when adding a migration.
|
/// Bump when adding a migration.
|
||||||
pub const SCHEMA_VERSION: i64 = 7;
|
pub const SCHEMA_VERSION: i64 = 8;
|
||||||
|
|
||||||
const MIGRATION_V1: &str = r#"
|
const MIGRATION_V1: &str = r#"
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
@@ -146,6 +146,13 @@ CREATE TABLE title_track_prefs (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// A language cannot tell two English subtitle tracks apart — "Signs & Songs"
|
||||||
|
/// and "Full Subtitles" — so the track's title is remembered beside it.
|
||||||
|
const MIGRATION_V8: &str = r#"
|
||||||
|
ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT;
|
||||||
|
ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT;
|
||||||
|
"#;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct OfflineFile {
|
pub struct OfflineFile {
|
||||||
pub revision_id: String,
|
pub revision_id: String,
|
||||||
@@ -157,6 +164,12 @@ pub struct TitleTrackPrefs {
|
|||||||
pub audio_language: Option<String>,
|
pub audio_language: Option<String>,
|
||||||
pub subtitle_language: Option<String>,
|
pub subtitle_language: Option<String>,
|
||||||
pub subtitles: bool,
|
pub subtitles: bool,
|
||||||
|
/// The name the muxer gave the chosen audio track, when it had one. What
|
||||||
|
/// picks between two tracks in the same language on the next episode.
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subtitle_title: Option<String>,
|
||||||
}
|
}
|
||||||
impl Default for TitleTrackPrefs {
|
impl Default for TitleTrackPrefs {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -164,10 +177,26 @@ impl Default for TitleTrackPrefs {
|
|||||||
audio_language: None,
|
audio_language: None,
|
||||||
subtitle_language: None,
|
subtitle_language: None,
|
||||||
subtitles: true,
|
subtitles: true,
|
||||||
|
audio_title: None,
|
||||||
|
subtitle_title: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `title_track_prefs` row, its columns starting at `first`.
|
||||||
|
fn title_track_prefs_row(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
first: usize,
|
||||||
|
) -> rusqlite::Result<TitleTrackPrefs> {
|
||||||
|
Ok(TitleTrackPrefs {
|
||||||
|
audio_language: row.get(first)?,
|
||||||
|
subtitle_language: row.get(first + 1)?,
|
||||||
|
subtitles: row.get::<_, i64>(first + 2)? != 0,
|
||||||
|
audio_title: row.get(first + 3)?,
|
||||||
|
subtitle_title: row.get(first + 4)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||||
const ABSOLUTE_SEASON: i64 = -1;
|
const ABSOLUTE_SEASON: i64 = -1;
|
||||||
|
|
||||||
@@ -297,6 +326,12 @@ impl Catalog {
|
|||||||
tx.pragma_update(None, "user_version", 7)?;
|
tx.pragma_update(None, "user_version", 7)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
}
|
}
|
||||||
|
if version < 8 {
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
tx.execute_batch(MIGRATION_V8)?;
|
||||||
|
tx.pragma_update(None, "user_version", 8)?;
|
||||||
|
tx.commit()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -510,27 +545,43 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
pub fn title_track_prefs(&self, title_key: &str) -> Result<Option<TitleTrackPrefs>> {
|
||||||
self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into)
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT audio_language, subtitle_language, subtitles, audio_title, subtitle_title \
|
||||||
|
FROM title_track_prefs WHERE title_key=?1",
|
||||||
|
params![title_key],
|
||||||
|
|row| title_track_prefs_row(row, 0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> {
|
||||||
self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?;
|
self.conn.execute(
|
||||||
|
"INSERT INTO title_track_prefs \
|
||||||
|
(title_key,audio_language,subtitle_language,subtitles,audio_title,subtitle_title) \
|
||||||
|
VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(title_key) DO UPDATE SET \
|
||||||
|
audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,\
|
||||||
|
subtitles=excluded.subtitles,audio_title=excluded.audio_title,\
|
||||||
|
subtitle_title=excluded.subtitle_title",
|
||||||
|
params![
|
||||||
|
title_key,
|
||||||
|
prefs.audio_language,
|
||||||
|
prefs.subtitle_language,
|
||||||
|
prefs.subtitles as i64,
|
||||||
|
prefs.audio_title,
|
||||||
|
prefs.subtitle_title
|
||||||
|
],
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
pub fn all_title_track_prefs(&self) -> Result<HashMap<String, TitleTrackPrefs>> {
|
||||||
let mut statement = self.conn.prepare(
|
let mut statement = self.conn.prepare(
|
||||||
"SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs",
|
"SELECT title_key, audio_language, subtitle_language, subtitles, audio_title, \
|
||||||
|
subtitle_title FROM title_track_prefs",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map([], |row| {
|
let rows =
|
||||||
Ok((
|
statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?;
|
||||||
row.get(0)?,
|
|
||||||
TitleTrackPrefs {
|
|
||||||
audio_language: row.get(1)?,
|
|
||||||
subtitle_language: row.get(2)?,
|
|
||||||
subtitles: row.get::<_, i64>(3)? != 0,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +610,9 @@ impl Catalog {
|
|||||||
|
|
||||||
/// Record where playback is.
|
/// Record where playback is.
|
||||||
pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> {
|
pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> {
|
||||||
self.conn.execute(
|
// Cached: playback saves its position every few seconds, and this is
|
||||||
|
// the one write that runs on a clock rather than on a click.
|
||||||
|
let mut upsert = self.conn.prepare_cached(
|
||||||
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||||
@@ -567,15 +620,15 @@ impl Catalog {
|
|||||||
duration_secs = excluded.duration_secs,
|
duration_secs = excluded.duration_secs,
|
||||||
watched = excluded.watched,
|
watched = excluded.watched,
|
||||||
updated_at = excluded.updated_at",
|
updated_at = excluded.updated_at",
|
||||||
params![
|
|
||||||
share_id,
|
|
||||||
link_id,
|
|
||||||
state.position_secs,
|
|
||||||
state.duration_secs,
|
|
||||||
state.watched as i64,
|
|
||||||
state.updated_at,
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
|
upsert.execute(params![
|
||||||
|
share_id,
|
||||||
|
link_id,
|
||||||
|
state.position_secs,
|
||||||
|
state.duration_secs,
|
||||||
|
state.watched as i64,
|
||||||
|
state.updated_at,
|
||||||
|
])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,6 +1444,37 @@ mod tests {
|
|||||||
assert_eq!(version, SCHEMA_VERSION);
|
assert_eq!(version, SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_titles_track_choice_round_trips_with_the_track_names() {
|
||||||
|
let catalog = Catalog::in_memory().expect("open");
|
||||||
|
assert_eq!(catalog.title_track_prefs("show").expect("read"), None);
|
||||||
|
|
||||||
|
let prefs = TitleTrackPrefs {
|
||||||
|
audio_language: Some("jpn".into()),
|
||||||
|
subtitle_language: Some("eng".into()),
|
||||||
|
subtitles: true,
|
||||||
|
audio_title: None,
|
||||||
|
subtitle_title: Some("Full Subtitles".into()),
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_title_track_prefs("show", &prefs)
|
||||||
|
.expect("write");
|
||||||
|
assert_eq!(
|
||||||
|
catalog.title_track_prefs("show").expect("read"),
|
||||||
|
Some(prefs.clone())
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = TitleTrackPrefs {
|
||||||
|
subtitle_title: Some("Signs & Songs".into()),
|
||||||
|
..prefs
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.set_title_track_prefs("show", &changed)
|
||||||
|
.expect("overwrite");
|
||||||
|
let all = catalog.all_title_track_prefs().expect("read all");
|
||||||
|
assert_eq!(all.get("show"), Some(&changed));
|
||||||
|
}
|
||||||
|
|
||||||
/// A newer schema is refused, not downgraded — reinterpreting it would
|
/// A newer schema is refused, not downgraded — reinterpreting it would
|
||||||
/// silently mangle whatever the newer build recorded.
|
/// silently mangle whatever the newer build recorded.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1539,6 +1623,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ impl AppDirs {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings only the desktop app has: hardware decoding, the cache budget.
|
||||||
|
pub fn desktop_prefs_file(&self) -> PathBuf {
|
||||||
|
self.config.join("desktop.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop window's size, for the next launch to open at.
|
||||||
|
pub fn window_file(&self) -> PathBuf {
|
||||||
|
self.config.join("window.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// The share list.
|
/// The share list.
|
||||||
pub fn shares_file(&self) -> PathBuf {
|
pub fn shares_file(&self) -> PathBuf {
|
||||||
self.config.join("shares.json")
|
self.config.join("shares.json")
|
||||||
|
|||||||
@@ -331,6 +331,28 @@ impl Library {
|
|||||||
self.titles.is_empty()
|
self.titles.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a new watch state for one file in place. Returns whether the
|
||||||
|
/// file is in the library.
|
||||||
|
///
|
||||||
|
/// What a save during playback or a "mark watched" needs: rebuilding the
|
||||||
|
/// whole library from the catalog for one changed row re-reads and re-sorts
|
||||||
|
/// every file, and on a large share that is a visible pause.
|
||||||
|
pub fn set_watch(&mut self, share_id: &str, link_id: &str, state: WatchState) -> bool {
|
||||||
|
let episode = self
|
||||||
|
.titles
|
||||||
|
.iter_mut()
|
||||||
|
.flat_map(|title| title.seasons.iter_mut())
|
||||||
|
.flat_map(|season| season.episodes.iter_mut())
|
||||||
|
.find(|episode| episode.node.share_id == share_id && episode.node.link_id == link_id);
|
||||||
|
match episode {
|
||||||
|
Some(episode) => {
|
||||||
|
episode.watch = Some(state);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> Option<&Title> {
|
pub fn get(&self, key: &str) -> Option<&Title> {
|
||||||
self.titles.iter().find(|title| title.key == key)
|
self.titles.iter().find(|title| title.key == key)
|
||||||
}
|
}
|
||||||
@@ -347,6 +369,26 @@ impl Library {
|
|||||||
titles
|
titles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every episode that has been started or finished, most recently
|
||||||
|
/// touched first. The history page.
|
||||||
|
///
|
||||||
|
/// An episode marked unwatched keeps a row with position zero, which is
|
||||||
|
/// "never played" for every purpose here, so it is left out.
|
||||||
|
pub fn history(&self) -> Vec<(&Title, &Episode)> {
|
||||||
|
let mut played: Vec<(&Title, &Episode)> = self
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.flat_map(|title| title.episodes().map(move |episode| (title, episode)))
|
||||||
|
.filter(|(_, episode)| {
|
||||||
|
episode
|
||||||
|
.watch
|
||||||
|
.is_some_and(|watch| watch.watched || watch.position_secs > 0.0)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played()));
|
||||||
|
played
|
||||||
|
}
|
||||||
|
|
||||||
/// Substring match over the display name and every filename under it, so
|
/// Substring match over the display name and every filename under it, so
|
||||||
/// searching for a release group or an episode name finds its title.
|
/// searching for a release group or an episode name finds its title.
|
||||||
pub fn search(&self, query: &str) -> Vec<&Title> {
|
pub fn search(&self, query: &str) -> Vec<&Title> {
|
||||||
@@ -495,6 +537,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_is_every_played_episode_newest_first_across_titles() {
|
||||||
|
let watch_state = HashMap::from([
|
||||||
|
(
|
||||||
|
("s".to_string(), "a".to_string()),
|
||||||
|
watch(600.0, 1400.0, false, 10),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
("s".to_string(), "c".to_string()),
|
||||||
|
watch(1400.0, 1400.0, true, 30),
|
||||||
|
),
|
||||||
|
// Marked unwatched again: a row at zero, which is not a play.
|
||||||
|
(
|
||||||
|
("s".to_string(), "b".to_string()),
|
||||||
|
watch(0.0, 1400.0, false, 40),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let library = Library::build(
|
||||||
|
vec![
|
||||||
|
node("s", "a", "Show - S01E01", Some(1), Some(1)),
|
||||||
|
node("s", "b", "Show - S01E02", Some(1), Some(2)),
|
||||||
|
node("s", "c", "Film", None, None),
|
||||||
|
node("s", "d", "Other - S01E01", Some(1), Some(1)),
|
||||||
|
],
|
||||||
|
&watch_state,
|
||||||
|
);
|
||||||
|
|
||||||
|
let order: Vec<&str> = library
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.map(|(_, episode)| episode.node.link_id.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(order, ["c", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn groups_episodes_into_seasons() {
|
fn groups_episodes_into_seasons() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
@@ -590,6 +667,21 @@ mod tests {
|
|||||||
assert_eq!(library.continue_watching().len(), 1);
|
assert_eq!(library.continue_watching().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_watch_state_set_in_place_matches_a_rebuild() {
|
||||||
|
let nodes = vec![
|
||||||
|
node("s", "1", "Show - S01E01", Some(1), Some(1)),
|
||||||
|
node("s", "2", "Show - S01E02", Some(1), Some(2)),
|
||||||
|
];
|
||||||
|
let mut states = HashMap::new();
|
||||||
|
let mut library = Library::build(nodes.clone(), &states);
|
||||||
|
let state = watch(700.0, 1440.0, false, 20);
|
||||||
|
assert!(library.set_watch("s", "2", state));
|
||||||
|
states.insert(("s".into(), "2".into()), state);
|
||||||
|
assert_eq!(library, Library::build(nodes, &states));
|
||||||
|
assert!(!library.set_watch("s", "9", state));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_up_falls_through_to_the_first_unwatched() {
|
fn next_up_falls_through_to_the_first_unwatched() {
|
||||||
let nodes = vec![
|
let nodes = vec![
|
||||||
|
|||||||
@@ -358,6 +358,19 @@ impl SharedLibrary {
|
|||||||
/// and a catalog that reorders itself between two identical crawls is
|
/// and a catalog that reorders itself between two identical crawls is
|
||||||
/// needlessly hard to diff.
|
/// needlessly hard to diff.
|
||||||
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
|
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
|
||||||
|
self.crawl_reporting(share_id, |_| {}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::crawl`], calling `progress` with how many nodes have been found
|
||||||
|
/// so far each time a folder's listing comes back.
|
||||||
|
///
|
||||||
|
/// A crawl of a large share takes minutes, and "crawling…" for minutes
|
||||||
|
/// reads as stuck. A count that keeps rising does not.
|
||||||
|
pub async fn crawl_reporting(
|
||||||
|
&self,
|
||||||
|
share_id: &str,
|
||||||
|
progress: impl Fn(usize),
|
||||||
|
) -> Result<Vec<Node>> {
|
||||||
use futures::stream::{StreamExt as _, TryStreamExt as _};
|
use futures::stream::{StreamExt as _, TryStreamExt as _};
|
||||||
|
|
||||||
let client = self
|
let client = self
|
||||||
@@ -392,6 +405,7 @@ impl SharedLibrary {
|
|||||||
}
|
}
|
||||||
found.push(child);
|
found.push(child);
|
||||||
}
|
}
|
||||||
|
progress(found.len());
|
||||||
}
|
}
|
||||||
level = next;
|
level = next;
|
||||||
}
|
}
|
||||||
@@ -428,6 +442,15 @@ fn client_configuration() -> ProtonClientConfiguration {
|
|||||||
/// Parsed here as well as in the SDK because the store needs a stable id for a
|
/// Parsed here as well as in the SDK because the store needs a stable id for a
|
||||||
/// share *before* it has opened it — and the id must not be derived from the
|
/// share *before* it has opened it — and the id must not be derived from the
|
||||||
/// password, which would put a secret in the config file by the back door.
|
/// password, which would put a secret in the config file by the back door.
|
||||||
|
/// The share token a link names, or why it is not a usable share link.
|
||||||
|
///
|
||||||
|
/// Public so a form can say what is wrong with a link while it is still being
|
||||||
|
/// pasted, rather than after a round trip through [`ShareStore::add`] — which
|
||||||
|
/// asks exactly this first.
|
||||||
|
pub fn share_token(url: &str) -> Result<String> {
|
||||||
|
token_from_url(url)
|
||||||
|
}
|
||||||
|
|
||||||
fn token_from_url(url: &str) -> Result<String> {
|
fn token_from_url(url: &str) -> Result<String> {
|
||||||
let (_, tail) = url
|
let (_, tail) = url
|
||||||
.split_once("/urls/")
|
.split_once("/urls/")
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ pub use player::{
|
|||||||
};
|
};
|
||||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||||
pub use render::VideoRenderer;
|
pub use render::VideoRenderer;
|
||||||
pub use tracks::{Track, TrackKind, language_name};
|
pub use tracks::{Track, TrackKind, language_name, pick_track};
|
||||||
@@ -94,6 +94,11 @@ pub enum PlayerEvent {
|
|||||||
/// Output volume, 0–100.
|
/// Output volume, 0–100.
|
||||||
Volume(f64),
|
Volume(f64),
|
||||||
Muted(bool),
|
Muted(bool),
|
||||||
|
/// How far into the file the demuxer has read ahead, in seconds from the
|
||||||
|
/// start — the end of what can play without another fetch.
|
||||||
|
Buffered(f64),
|
||||||
|
/// Whether playback is held waiting for data.
|
||||||
|
Buffering(bool),
|
||||||
/// What the file contains and what is playing of it.
|
/// What the file contains and what is playing of it.
|
||||||
///
|
///
|
||||||
/// Not an mpv event: mpv reports a track list as a node property, and this
|
/// Not an mpv event: mpv reports a track list as a node property, and this
|
||||||
@@ -308,6 +313,10 @@ impl Player {
|
|||||||
// it clamps a restored value to.
|
// it clamps a restored value to.
|
||||||
self.mpv.observe_property("volume", Format::Double, 0)?;
|
self.mpv.observe_property("volume", Format::Double, 0)?;
|
||||||
self.mpv.observe_property("mute", Format::Flag, 0)?;
|
self.mpv.observe_property("mute", Format::Flag, 0)?;
|
||||||
|
self.mpv
|
||||||
|
.observe_property("demuxer-cache-time", Format::Double, 0)?;
|
||||||
|
self.mpv
|
||||||
|
.observe_property("paused-for-cache", Format::Flag, 0)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +458,32 @@ impl Player {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where subtitles sit, as a percentage of the window's height from the
|
||||||
|
/// top: 100 is mpv's usual place along the bottom edge.
|
||||||
|
///
|
||||||
|
/// `sub-pos` rather than a margin, because it moves styled (ASS) subtitles
|
||||||
|
/// too — and those are most of what an anime library carries.
|
||||||
|
pub fn set_subtitle_position(&self, percent: f64) -> Result<()> {
|
||||||
|
self.mpv
|
||||||
|
.set_property("sub-pos", percent.clamp(0.0, 150.0).round() as i64)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
|
||||||
|
/// default and what keeps a voice a voice at 1.5×.
|
||||||
|
pub fn set_speed(&self, speed: f64) -> Result<()> {
|
||||||
|
self.mpv.set_property("speed", speed.clamp(0.25, 4.0))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the current frame, without subtitles or anything drawn over it,
|
||||||
|
/// to `path`. The format follows the extension.
|
||||||
|
pub fn screenshot(&self, path: &std::path::Path) -> Result<()> {
|
||||||
|
let path = path.to_string_lossy();
|
||||||
|
self.mpv.command("screenshot-to-file", &[&path, "video"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<()> {
|
pub fn stop(&self) -> Result<()> {
|
||||||
self.mpv.command("stop", &[])?;
|
self.mpv.command("stop", &[])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -505,6 +540,8 @@ fn translate(event: Event<'_>) -> PlayerEvent {
|
|||||||
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
|
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
|
||||||
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
|
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
|
||||||
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value),
|
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value),
|
||||||
|
("demuxer-cache-time", PropertyData::Double(value)) => PlayerEvent::Buffered(value),
|
||||||
|
("paused-for-cache", PropertyData::Flag(value)) => PlayerEvent::Buffering(value),
|
||||||
_ => PlayerEvent::Other,
|
_ => PlayerEvent::Other,
|
||||||
},
|
},
|
||||||
_ => PlayerEvent::Other,
|
_ => PlayerEvent::Other,
|
||||||
|
|||||||
@@ -107,6 +107,56 @@ pub(crate) fn read(mpv: &Mpv) -> Vec<Track> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The track of `kind` that matches a choice remembered from another file.
|
||||||
|
///
|
||||||
|
/// A title outranks a language: two English subtitle tracks — "Signs & Songs"
|
||||||
|
/// and "Full Subtitles" — share a language and differ only by name, and the
|
||||||
|
/// name is what was picked. A title in the wrong language is not a match, since
|
||||||
|
/// "Full Subtitles" in German is not what someone reading English chose. Without
|
||||||
|
/// a title match, a track in the language wins, the container's default first.
|
||||||
|
/// `None` when nothing matches, which leaves the current selection standing.
|
||||||
|
pub fn pick_track(
|
||||||
|
tracks: &[Track],
|
||||||
|
kind: TrackKind,
|
||||||
|
language: Option<&str>,
|
||||||
|
title: Option<&str>,
|
||||||
|
) -> Option<i64> {
|
||||||
|
let in_language = |track: &Track| match (language, track.language.as_deref()) {
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(wanted), Some(have)) => same_language(wanted, have),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
};
|
||||||
|
let of_kind = || tracks.iter().filter(move |track| track.kind == kind);
|
||||||
|
|
||||||
|
if let Some(title) = title
|
||||||
|
&& let Some(track) = of_kind().find(|track| {
|
||||||
|
in_language(track)
|
||||||
|
&& track
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|have| have.eq_ignore_ascii_case(title))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Some(track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
language?;
|
||||||
|
let mut matching = of_kind().filter(|track| in_language(track));
|
||||||
|
let first = matching.next()?;
|
||||||
|
Some(
|
||||||
|
std::iter::once(first)
|
||||||
|
.chain(matching)
|
||||||
|
.find(|track| track.default)
|
||||||
|
.unwrap_or(first)
|
||||||
|
.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two container tags name the same language: "en" and "eng" do.
|
||||||
|
fn same_language(a: &str, b: &str) -> bool {
|
||||||
|
a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
|
||||||
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
|
||||||
let id: i64 = mpv
|
let id: i64 = mpv
|
||||||
@@ -242,6 +292,87 @@ mod tests {
|
|||||||
assert_eq!(language_name("mis"), "mis");
|
assert_eq!(language_name("mis"), "mis");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track {
|
||||||
|
Track {
|
||||||
|
language: language.map(Into::into),
|
||||||
|
title: title.map(Into::into),
|
||||||
|
..track(kind, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_remembered_title_picks_between_tracks_of_one_language() {
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Subtitle, 1, Some("eng"), Some("Signs & Songs")),
|
||||||
|
named(TrackKind::Subtitle, 2, Some("eng"), Some("Full Subtitles")),
|
||||||
|
];
|
||||||
|
let mut signs = tracks[0].clone();
|
||||||
|
signs.default = true;
|
||||||
|
let tracks = [signs, tracks[1].clone()];
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(
|
||||||
|
&tracks,
|
||||||
|
TrackKind::Subtitle,
|
||||||
|
Some("eng"),
|
||||||
|
Some("full subtitles")
|
||||||
|
),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_in_another_language_is_not_the_one_that_was_chosen() {
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Subtitle, 1, Some("ger"), Some("Full Subtitles")),
|
||||||
|
named(TrackKind::Subtitle, 2, Some("en"), Some("Dialogue")),
|
||||||
|
];
|
||||||
|
// Falls back to the language, and "en" is the same language as "eng".
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(
|
||||||
|
&tracks,
|
||||||
|
TrackKind::Subtitle,
|
||||||
|
Some("eng"),
|
||||||
|
Some("Full Subtitles")
|
||||||
|
),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untagged_track_is_found_by_its_title_alone() {
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Audio, 1, None, Some("Stereo")),
|
||||||
|
named(TrackKind::Audio, 2, None, Some("Commentary")),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, None, Some("Commentary")),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_language_match_prefers_the_default_track_and_nothing_matching_is_none() {
|
||||||
|
let mut second = named(TrackKind::Audio, 2, Some("jpn"), None);
|
||||||
|
second.default = true;
|
||||||
|
let tracks = [
|
||||||
|
named(TrackKind::Audio, 1, Some("jpn"), Some("Commentary")),
|
||||||
|
second,
|
||||||
|
named(TrackKind::Subtitle, 3, Some("jpn"), None),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, Some("ja"), None),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, Some("fre"), None),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_track(&tracks, TrackKind::Audio, None, Some("Stereo")),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_kinds_the_player_can_select_are_tracks() {
|
fn only_the_kinds_the_player_can_select_are_tracks() {
|
||||||
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
|
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ pub struct DiskStats {
|
|||||||
|
|
||||||
pub(crate) struct DiskCache {
|
pub(crate) struct DiskCache {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
budget: u64,
|
/// Atomic so it can be changed under a running cache, from settings.
|
||||||
|
budget: std::sync::atomic::AtomicU64,
|
||||||
index: Mutex<Index>,
|
index: Mutex<Index>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ impl DiskCache {
|
|||||||
|
|
||||||
let cache = Self {
|
let cache = Self {
|
||||||
root: config.root,
|
root: config.root,
|
||||||
budget: config.budget_bytes,
|
budget: std::sync::atomic::AtomicU64::new(config.budget_bytes),
|
||||||
index: Mutex::new(Index {
|
index: Mutex::new(Index {
|
||||||
lru,
|
lru,
|
||||||
bytes,
|
bytes,
|
||||||
@@ -169,7 +170,7 @@ impl DiskCache {
|
|||||||
/// written is a slower app, not a broken one.
|
/// written is a slower app, not a broken one.
|
||||||
pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) {
|
pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) {
|
||||||
let len = block.len() as u64;
|
let len = block.len() as u64;
|
||||||
if len > self.budget {
|
if len > self.budget() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +191,30 @@ impl DiskCache {
|
|||||||
self.evict_to_budget().await;
|
self.evict_to_budget().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn budget(&self) -> u64 {
|
||||||
|
self.budget.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change the budget, shrinking the cache to it at once if it is now over.
|
||||||
|
pub(crate) async fn set_budget(&self, bytes: u64) {
|
||||||
|
self.budget
|
||||||
|
.store(bytes, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.evict_to_budget().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every entry. The cache carries on, empty, under the same budget.
|
||||||
|
pub(crate) async fn clear(&self) {
|
||||||
|
let doomed: Vec<PathBuf> = {
|
||||||
|
let mut index = self.lock();
|
||||||
|
let doomed = std::iter::from_fn(|| index.lru.pop_lru().map(|(path, _)| path)).collect();
|
||||||
|
index.bytes = 0;
|
||||||
|
doomed
|
||||||
|
};
|
||||||
|
for path in doomed {
|
||||||
|
self.discard(&path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn stats(&self) -> DiskStats {
|
pub(crate) fn stats(&self) -> DiskStats {
|
||||||
let index = self.lock();
|
let index = self.lock();
|
||||||
DiskStats {
|
DiskStats {
|
||||||
@@ -204,7 +229,7 @@ impl DiskCache {
|
|||||||
loop {
|
loop {
|
||||||
let doomed = {
|
let doomed = {
|
||||||
let mut index = self.lock();
|
let mut index = self.lock();
|
||||||
if index.bytes <= self.budget {
|
if index.bytes <= self.budget() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match index.lru.pop_lru() {
|
match index.lru.pop_lru() {
|
||||||
@@ -321,11 +346,9 @@ async fn write_entry(path: &Path, block: &[u8]) -> Result<()> {
|
|||||||
}
|
}
|
||||||
tokio::fs::rename(&temp, path).await?;
|
tokio::fs::rename(&temp, path).await?;
|
||||||
|
|
||||||
let mut sidecar = tokio::fs::File::create(meta_path(path)).await?;
|
// Not synced. The block's sync is the one the ordering depends on; a
|
||||||
sidecar
|
// sidecar lost to a crash only turns a valid entry into a miss.
|
||||||
.write_all(block.len().to_string().as_bytes())
|
tokio::fs::write(meta_path(path), block.len().to_string()).await?;
|
||||||
.await?;
|
|
||||||
sidecar.sync_all().await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +536,31 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_cleared_cache_is_empty_on_disk_and_in_its_index() {
|
||||||
|
let root = temp_root("clear");
|
||||||
|
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
|
||||||
|
for index in 0..3 {
|
||||||
|
cache.put(&key("a", "rev1", index), &[7u8; 100]).await;
|
||||||
|
}
|
||||||
|
cache.clear().await;
|
||||||
|
assert_eq!(cache.stats().stored_bytes, 0);
|
||||||
|
assert!(cache.get(&key("a", "rev1", 0)).await.is_none());
|
||||||
|
let reopened = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
|
||||||
|
assert_eq!(reopened.stats().entries, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lowering_the_budget_shrinks_a_running_cache() {
|
||||||
|
let root = temp_root("lower");
|
||||||
|
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
|
||||||
|
for index in 0..5 {
|
||||||
|
cache.put(&key("a", "rev1", index), &[7u8; 100]).await;
|
||||||
|
}
|
||||||
|
cache.set_budget(250).await;
|
||||||
|
assert!(cache.stats().stored_bytes <= 250);
|
||||||
|
}
|
||||||
|
|
||||||
/// Reopening with a smaller budget must shrink the cache, not sit over it
|
/// Reopening with a smaller budget must shrink the cache, not sit over it
|
||||||
/// until enough new blocks arrive to trigger eviction.
|
/// until enough new blocks arrive to trigger eviction.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -10,15 +10,23 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
||||||
|
use proton_sdk::error::ProtonError;
|
||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
|
|
||||||
use crate::block::{BlockMap, BlockSource, SharedBlocks};
|
use crate::block::{BlockMap, BlockSource, SharedBlocks};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::source::RevisionOpener;
|
use crate::source::RevisionOpener;
|
||||||
|
|
||||||
|
/// How many times one block is fetched before its failure reaches the reader.
|
||||||
|
const BLOCK_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// The pause before a block's second fetch, doubled before each one after.
|
||||||
|
const BLOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// One open revision on a public link.
|
/// One open revision on a public link.
|
||||||
pub struct RevisionBlocks {
|
pub struct RevisionBlocks {
|
||||||
reader: proton_drive_rs::RevisionReader,
|
reader: proton_drive_rs::RevisionReader,
|
||||||
@@ -50,7 +58,7 @@ impl BlockSource for RevisionBlocks {
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let block = self.reader.read_at(start, size).await?;
|
let block = fetch_retrying(BLOCK_RETRY_DELAY, || self.reader.read_at(start, size)).await?;
|
||||||
// A short block here is not a tail — the map says how long it is. It
|
// A short block here is not a tail — the map says how long it is. It
|
||||||
// means the revision changed underneath us or the block table lied, and
|
// means the revision changed underneath us or the block table lied, and
|
||||||
// serving it would silently shift every later byte.
|
// serving it would silently shift every later byte.
|
||||||
@@ -64,6 +72,38 @@ impl BlockSource for RevisionBlocks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a block, again if the failure is one a replay could get past.
|
||||||
|
///
|
||||||
|
/// The SDK retries a request that fails before its response starts, but not a
|
||||||
|
/// block body cut off part way through: that error reaches us as it happened,
|
||||||
|
/// and without this it stops playback or fails a download. Its keep-alive pings
|
||||||
|
/// turn a silently dead connection into exactly that error, seconds in rather
|
||||||
|
/// than at the storage timeout, and the next attempt goes out on a new
|
||||||
|
/// connection. [`ProtonError::is_retriable`] decides what counts, so a
|
||||||
|
/// permanent refusal still fails on the first attempt.
|
||||||
|
async fn fetch_retrying<T, F, Fut>(
|
||||||
|
first_delay: Duration,
|
||||||
|
mut fetch: F,
|
||||||
|
) -> std::result::Result<T, ProtonError>
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: Future<Output = std::result::Result<T, ProtonError>>,
|
||||||
|
{
|
||||||
|
let mut delay = first_delay;
|
||||||
|
let mut attempt = 1;
|
||||||
|
loop {
|
||||||
|
match fetch().await {
|
||||||
|
Err(error) if attempt < BLOCK_ATTEMPTS && error.is_retriable() => {
|
||||||
|
tracing::debug!(attempt, %error, "block fetch failed; fetching again");
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
delay *= 2;
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
result => return result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
|
/// A fully-downloaded revision. It uses the same block seam as Proton so mpv
|
||||||
/// can seek normally without a second playback path.
|
/// can seek normally without a second playback path.
|
||||||
pub struct FileBlocks {
|
pub struct FileBlocks {
|
||||||
@@ -136,8 +176,60 @@ impl RevisionOpener for LibraryOpener {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
use proton_sdk::api::ResponseCode;
|
||||||
|
use proton_sdk::error::ProtonApiError;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn api_error(http_status: u16) -> ProtonError {
|
||||||
|
ProtonError::Api(ProtonApiError {
|
||||||
|
code: ResponseCode::Unknown,
|
||||||
|
http_status,
|
||||||
|
message: String::new(),
|
||||||
|
details: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run [`fetch_retrying`] over a fetch that fails with `failures` in turn and
|
||||||
|
/// then succeeds, returning the result and how many fetches it took.
|
||||||
|
async fn fetch_through(
|
||||||
|
failures: Vec<ProtonError>,
|
||||||
|
) -> (std::result::Result<u8, ProtonError>, u32) {
|
||||||
|
let calls = AtomicU32::new(0);
|
||||||
|
let failures = std::sync::Mutex::new(failures.into_iter());
|
||||||
|
let result = fetch_retrying(Duration::ZERO, || {
|
||||||
|
calls.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let next = failures.lock().unwrap().next();
|
||||||
|
async move { next.map_or(Ok(7), Err) }
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
(result, calls.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_block_that_fails_retriably_is_fetched_again() {
|
||||||
|
let (result, calls) = fetch_through(vec![api_error(503)]).await;
|
||||||
|
assert_eq!(result.expect("second fetch succeeds"), 7);
|
||||||
|
assert_eq!(calls, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_permanent_refusal_is_not_fetched_again() {
|
||||||
|
let (result, calls) = fetch_through(vec![api_error(403)]).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(calls, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_block_that_keeps_failing_returns_its_last_error() {
|
||||||
|
let (result, calls) =
|
||||||
|
fetch_through((0..BLOCK_ATTEMPTS + 1).map(|_| api_error(503)).collect()).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(calls, BLOCK_ATTEMPTS);
|
||||||
|
}
|
||||||
|
|
||||||
/// The player hands its stream to mpv's demuxer thread, so everything from
|
/// The player hands its stream to mpv's demuxer thread, so everything from
|
||||||
/// the opener down has to cross threads. A compile-time guard, because the
|
/// the opener down has to cross threads. A compile-time guard, because the
|
||||||
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in
|
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in
|
||||||
|
|||||||
@@ -155,6 +155,24 @@ impl StreamSource {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a file to copy it out whole, rather than to play it.
|
||||||
|
///
|
||||||
|
/// Not the playback stream, and not shared with it: its blocks are read
|
||||||
|
/// once and written to a file, so caching them in memory only evicts the
|
||||||
|
/// blocks a viewer is about to need, and caching them on disk stores every
|
||||||
|
/// byte twice while pushing out what was just watched. It does still read
|
||||||
|
/// the disk cache — an episode watched and then downloaded is not fetched
|
||||||
|
/// again.
|
||||||
|
pub async fn open_for_copy(&self, share_id: &str, uid: &NodeUid) -> Result<VideoStream> {
|
||||||
|
let blocks = self.inner.opener.open(share_id, uid).await?;
|
||||||
|
Ok(VideoStream::copying(
|
||||||
|
uid.clone(),
|
||||||
|
blocks,
|
||||||
|
Arc::new(BlockRing::new(0)),
|
||||||
|
self.inner.disk.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a stream and everything cached for it in memory.
|
/// Drop a stream and everything cached for it in memory.
|
||||||
///
|
///
|
||||||
/// The disk cache is deliberately untouched: it exists precisely so the next
|
/// The disk cache is deliberately untouched: it exists precisely so the next
|
||||||
@@ -182,6 +200,21 @@ impl StreamSource {
|
|||||||
pub fn disk_stats(&self) -> Option<crate::disk::DiskStats> {
|
pub fn disk_stats(&self) -> Option<crate::disk::DiskStats> {
|
||||||
self.inner.disk.as_ref().map(|disk| disk.stats())
|
self.inner.disk.as_ref().map(|disk| disk.stats())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change the disk cache's budget, evicting at once if it is now over.
|
||||||
|
pub async fn set_disk_budget(&self, bytes: u64) {
|
||||||
|
if let Some(disk) = &self.inner.disk {
|
||||||
|
disk.set_budget(bytes).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty the disk cache. Streams already open carry on; what they had
|
||||||
|
/// cached is fetched again if it is wanted.
|
||||||
|
pub async fn clear_disk_cache(&self) {
|
||||||
|
if let Some(disk) = &self.inner.disk {
|
||||||
|
disk.clear().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Inner {
|
impl Inner {
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ struct Inner {
|
|||||||
map: BlockMap,
|
map: BlockMap,
|
||||||
ring: Arc<BlockRing>,
|
ring: Arc<BlockRing>,
|
||||||
disk: Option<Arc<DiskCache>>,
|
disk: Option<Arc<DiskCache>>,
|
||||||
|
/// Whether blocks fetched from the network are written to `disk`. Off for
|
||||||
|
/// a stream being copied out to a file, which reads what the cache already
|
||||||
|
/// holds but should not push what a viewer just watched out of it to make
|
||||||
|
/// room for bytes that are about to exist on disk anyway.
|
||||||
|
fill_disk: bool,
|
||||||
/// One fetch per block, however many readers want it.
|
/// One fetch per block, however many readers want it.
|
||||||
flight: Arc<SingleFlight<BlockKey, CachedBlock>>,
|
flight: Arc<SingleFlight<BlockKey, CachedBlock>>,
|
||||||
readahead: usize,
|
readahead: usize,
|
||||||
@@ -131,6 +136,28 @@ impl VideoStream {
|
|||||||
ring: Arc<BlockRing>,
|
ring: Arc<BlockRing>,
|
||||||
disk: Option<Arc<DiskCache>>,
|
disk: Option<Arc<DiskCache>>,
|
||||||
readahead: usize,
|
readahead: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self::build(uid, blocks, ring, disk, readahead, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stream that reads through the disk cache but never adds to it. See
|
||||||
|
/// [`crate::StreamSource::open_for_copy`].
|
||||||
|
pub(crate) fn copying(
|
||||||
|
uid: NodeUid,
|
||||||
|
blocks: SharedBlocks,
|
||||||
|
ring: Arc<BlockRing>,
|
||||||
|
disk: Option<Arc<DiskCache>>,
|
||||||
|
) -> Self {
|
||||||
|
Self::build(uid, blocks, ring, disk, 0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
uid: NodeUid,
|
||||||
|
blocks: SharedBlocks,
|
||||||
|
ring: Arc<BlockRing>,
|
||||||
|
disk: Option<Arc<DiskCache>>,
|
||||||
|
readahead: usize,
|
||||||
|
fill_disk: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let map = BlockMap::new(blocks.block_sizes());
|
let map = BlockMap::new(blocks.block_sizes());
|
||||||
let readahead = clamp_readahead(readahead, ring.budget(), blocks.block_sizes());
|
let readahead = clamp_readahead(readahead, ring.budget(), blocks.block_sizes());
|
||||||
@@ -141,6 +168,7 @@ impl VideoStream {
|
|||||||
map,
|
map,
|
||||||
ring,
|
ring,
|
||||||
disk,
|
disk,
|
||||||
|
fill_disk,
|
||||||
flight: Arc::new(SingleFlight::new()),
|
flight: Arc::new(SingleFlight::new()),
|
||||||
readahead,
|
readahead,
|
||||||
readahead_slots: Arc::new(Semaphore::new(readahead.max(1))),
|
readahead_slots: Arc::new(Semaphore::new(readahead.max(1))),
|
||||||
@@ -176,6 +204,38 @@ impl VideoStream {
|
|||||||
self.inner.blocks.block_sizes()
|
self.inner.blocks.block_sizes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start fetching the first and last blocks, without waiting for them.
|
||||||
|
///
|
||||||
|
/// Every demuxer reads the head of a file first, and the containers this
|
||||||
|
/// app plays keep their index at the tail — Matroska's cues, an MP4's
|
||||||
|
/// `moov` when it was not written for streaming. Opening a file is
|
||||||
|
/// therefore two round trips, one after the other. Asked for together,
|
||||||
|
/// they are one. The player's own reads join these through the
|
||||||
|
/// single-flight, so nothing is fetched twice.
|
||||||
|
///
|
||||||
|
/// Detached rather than read-ahead: the jump to the tail looks like a
|
||||||
|
/// seek, and a seek cancels read-ahead — which here would cancel the very
|
||||||
|
/// fetch it was meant to have started.
|
||||||
|
pub fn warm_edges(&self) {
|
||||||
|
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let count = self.inner.map.len();
|
||||||
|
let edges: &[usize] = match count {
|
||||||
|
0 => &[],
|
||||||
|
1 => &[0],
|
||||||
|
_ => &[0, count - 1],
|
||||||
|
};
|
||||||
|
for &index in edges {
|
||||||
|
let inner = Arc::clone(&self.inner);
|
||||||
|
runtime.spawn(async move {
|
||||||
|
if let Err(error) = block_at(inner, index).await {
|
||||||
|
tracing::debug!(index, %error, "warming an edge block failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fill `buf` from `offset`, returning how many bytes were written.
|
/// Fill `buf` from `offset`, returning how many bytes were written.
|
||||||
///
|
///
|
||||||
/// A read at or past EOF returns 0 rather than failing — that is how a
|
/// A read at or past EOF returns 0 rather than failing — that is how a
|
||||||
@@ -413,10 +473,17 @@ async fn block_at(inner: Arc<Inner>, index: usize) -> Result<CachedBlock> {
|
|||||||
.fetched_bytes
|
.fetched_bytes
|
||||||
.fetch_add(bytes.len() as u64, Ordering::Relaxed);
|
.fetch_add(bytes.len() as u64, Ordering::Relaxed);
|
||||||
|
|
||||||
if let Some(disk) = inner.disk.as_ref() {
|
|
||||||
disk.put(&fetch_key, &bytes).await;
|
|
||||||
}
|
|
||||||
let block: CachedBlock = bytes.into();
|
let block: CachedBlock = bytes.into();
|
||||||
|
// Written behind the read, not before it: the cache is disposable,
|
||||||
|
// and a block the player is waiting for should not also wait for a
|
||||||
|
// 4 MiB write and its fsync. That wait was on every cold block,
|
||||||
|
// which is to say on the first frame and on every seek.
|
||||||
|
if let Some(disk) = inner.disk.as_ref().filter(|_| inner.fill_disk) {
|
||||||
|
let disk = Arc::clone(disk);
|
||||||
|
let key = fetch_key.clone();
|
||||||
|
let bytes = Arc::clone(&block);
|
||||||
|
tokio::spawn(async move { disk.put(&key, &bytes).await });
|
||||||
|
}
|
||||||
inner.ring.insert(fetch_key, Arc::clone(&block));
|
inner.ring.insert(fetch_key, Arc::clone(&block));
|
||||||
Ok(block)
|
Ok(block)
|
||||||
})
|
})
|
||||||
@@ -891,4 +958,85 @@ mod tests {
|
|||||||
assert_eq!(stream.read_at(80, &mut buf).await.unwrap(), 20);
|
assert_eq!(stream.read_at(80, &mut buf).await.unwrap(), 20);
|
||||||
assert_eq!(&buf[..20], &expected(80, 20)[..]);
|
assert_eq!(&buf[..20], &expected(80, 20)[..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn disk_in(tag: &str) -> Arc<DiskCache> {
|
||||||
|
let root = std::env::temp_dir().join(format!("pstr-stream-{tag}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
Arc::new(
|
||||||
|
DiskCache::open(crate::disk::DiskCacheConfig::new(root))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits on a detached task without sleeping for a guessed duration.
|
||||||
|
async fn eventually(mut done: impl FnMut() -> bool) -> bool {
|
||||||
|
for _ in 0..200 {
|
||||||
|
if done() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn warming_fetches_the_first_and_last_blocks_and_nothing_between() {
|
||||||
|
let fake = Fake::new(&[1000; 10]);
|
||||||
|
let stream = stream_over(Arc::clone(&fake), 0);
|
||||||
|
|
||||||
|
stream.warm_edges();
|
||||||
|
assert!(eventually(|| fake.fetches_of(0) == 1 && fake.fetches_of(9) == 1).await);
|
||||||
|
assert_eq!(fake.fetches(), 2);
|
||||||
|
|
||||||
|
// And the player's own reads are then served without a second fetch.
|
||||||
|
stream.read_range(0, 10).await.unwrap();
|
||||||
|
stream.read_range(9_990, 10).await.unwrap();
|
||||||
|
assert_eq!(fake.fetches(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it() {
|
||||||
|
let disk = disk_in("fill").await;
|
||||||
|
let fake = Fake::new(&[1000, 1000]);
|
||||||
|
let stream = VideoStream::new(
|
||||||
|
uid(),
|
||||||
|
fake,
|
||||||
|
Arc::new(BlockRing::new(DEFAULT_RING_BYTES)),
|
||||||
|
Some(Arc::clone(&disk)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(stream.read_range(0, 10).await.unwrap(), expected(0, 10));
|
||||||
|
assert!(eventually(|| disk.stats().entries == 1).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_copying_stream_reads_the_disk_cache_but_never_adds_to_it() {
|
||||||
|
let disk = disk_in("copy").await;
|
||||||
|
let fake = Fake::new(&[1000, 1000]);
|
||||||
|
let key = BlockKey::new(&uid(), "rev1", 0);
|
||||||
|
disk.put(&key, &expected(0, 1000)).await;
|
||||||
|
|
||||||
|
let stream = VideoStream::copying(
|
||||||
|
uid(),
|
||||||
|
Arc::clone(&fake) as SharedBlocks,
|
||||||
|
Arc::new(BlockRing::new(0)),
|
||||||
|
Some(Arc::clone(&disk)),
|
||||||
|
);
|
||||||
|
assert_eq!(stream.read_range(0, 2000).await.unwrap(), expected(0, 2000));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fake.fetches_of(0),
|
||||||
|
0,
|
||||||
|
"block 0 should have come from the disk"
|
||||||
|
);
|
||||||
|
assert_eq!(fake.fetches_of(1), 1);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
assert_eq!(
|
||||||
|
disk.stats().entries,
|
||||||
|
1,
|
||||||
|
"the copy wrote into the playback cache"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -120,4 +120,9 @@ an edge case.
|
|||||||
| Catalog | data dir, `catalog.db` | Yes — recrawl |
|
| Catalog | data dir, `catalog.db` | Yes — recrawl |
|
||||||
| Watch state | same DB, own table | **No** — deliberately survives a recrawl |
|
| Watch state | same DB, own table | **No** — deliberately survives a recrawl |
|
||||||
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
|
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
|
||||||
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each |
|
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
|
||||||
|
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
|
||||||
|
| Desktop-only settings | config dir, `desktop.json` | Defaults if missing — hardware decoding, cache budget; never overwritten when unparseable |
|
||||||
|
| Window size | config dir, `window.json` | Defaults if missing or implausible |
|
||||||
|
| Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing |
|
||||||
|
| Notification app id (Windows) | `HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream`, icon in cache dir | Yes — written again the first time a session notifies |
|
||||||